<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Loomcycle blog</title>
    <link>https://loomcycle.dev/blog/</link>
    <atom:link href="https://loomcycle.dev/blog/feed.xml" rel="self" type="application/rss+xml" />
    <description>Engineering writeups from the loomcycle project. Benchmark findings, architecture decisions, lessons learned.</description>
    <language>en-us</language>
    <copyright>Copyright 2026 Dennis Gubsky. Apache-2.0 licensed software; blog posts are All Rights Reserved unless noted otherwise.</copyright>
    <lastBuildDate>Wed, 08 Jul 2026 18:00:00 GMT</lastBuildDate>
    <ttl>1440</ttl>

    <item>
      <title>LoomBoard v0.1.2 ships: a desktop chat app for loomcycle</title>
      <link>https://loomcycle.dev/blog/loomboard-ships.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/loomboard-ships.html</guid>
      <pubDate>Wed, 08 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>LoomBoard v0.1.2 ships as the user-facing chat app for the loomcycle agentic runtime. Four packagings on one React codebase: a Tauri v2 native desktop app for macOS (universal DMG), Windows (NSIS + MSI), and Linux (AppImage + deb); an npm CLI runner (@loomboard/app) that opens the same UI in your default browser via a local reverse-proxy that sidesteps browser CORS; an embeddable React component (@loomboard/chat) with peer deps react + react-dom + @loomcycle/client; and a Chrome MV3 side-panel extension that registers browser_read_page / fill / click / navigate as RFC BC client-executed tools. Thin client — no LoomBoard-side backend, database, auth, or message queue. Six shipped capabilities: streamed output with tool calls + reasoning traces from every provider, live token/throughput/context HUD + context compaction, Interruption answers in place, image + PDF + DOCX attachments (pdfjs-dist + mammoth extract text browser-side), embedded Skills + MCP catalog via the reusable @loomcycle/library React component, and inline RFC AW EventLimit budget warnings (amber soft / red hard). Chrome extension actuation gated by a Confirm-vs-Auto toggle; sensitive fields always require confirmation. All builds UNSIGNED for v0.1.x (macOS Gatekeeper right-click → Open; Windows SmartScreen "More info" → "Run anyway"). Signed builds queued for v0.2. Board mode + Explorer mode target v0.3. Android/iOS via Tauri mobile after that. Chrome Web Store submission after v0.2 signing. Apache-2.0. Downloads at loomcycle.dev/loomboard.</description>
    </item>

    <item>
      <title>Client-executed tools: the agent's eyes and hands (v1.16.0 to v1.16.1)</title>
      <link>https://loomcycle.dev/blog/client-executed-tools-the-agents-eyes-and-hands.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/client-executed-tools-the-agents-eyes-and-hands.html</guid>
      <pubDate>Tue, 07 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>RFC BC inverts the tool-execution direction. A client (browser, IDE, mobile app) opens a WebSocket to the runtime and registers its own tools. When the agent calls one, the runtime routes the invoke over the socket to the connected client; the client runs the tool locally and returns the result. Four layers: a transport-agnostic registry (invoke↔result correlation, per-principal connection map, per-key connection cap, delegate-and-block Invoke), a bearer-authed GET /v1/client-tools WebSocket endpoint (bearer rides Sec-WebSocket-Protocol because browsers can't set Authorization on a WS handshake), dispatch through client__-prefixed advertised tools (grants gated by tools: allowlist globs), and a TypeScript connectClientTools helper in @loomcycle/client that auto-reconnects. v1.16.1 fixes the wire-safe name (client: to client__) because Anthropic, OpenAI, and Ollama all reject colons in tool names, so v1.16.0 was uncallable end-to-end until the rename. LoomBoard's Chrome side-panel extension is the first customer, migrating from a channel-bridge to RFC BC in the same three-day window with browser_read_page, fill, click, and navigate tools plus a Confirm-vs-Auto approval bar.</description>
    </item>

    <item>
      <title>Search providers, first-class (v1.15.0 to v1.15.1)</title>
      <link>https://loomcycle.dev/blog/search-providers-first-class.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/search-providers-first-class.html</guid>
      <pubDate>Mon, 06 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Two weeks after the "Brave killed the free tier" research landed as RFC AR, the shipped answer: WebSearch becomes a multi-provider fallback circuit. A new internal/search connector package with a Provider interface and five pure-HTTP drivers (Brave, Serper, Exa, Tavily, SearXNG). A search_providers config, a search_priority cascade, per-AgentDef.search_providers overlay in content_sha256. The fallback circuit walks the cascade, resolves each provider's key via ResolveKeyOrOperator (RFC AR/AX honored), and falls over silently on error / empty / un-keyable. The model sees the same numbered result text regardless of which provider answered. Routing view gains a search block with keyable / available / selected / reachable / last_error per entry; Web UI Routing page renders it beside the LLM cascade. v1.15.1 adds an opt-in provenance footer (LOOMCYCLE_WEBSEARCH_PROVENANCE=1) that names the winning provider inline — off by default for byte-identical output. Also: search_providers documented in loomcycle.example.yaml; Library detail-panel independent scroll fixed. Bumps @loomcycle/client to 1.15.0 to publish LibraryAgentDefinition.search_providers; Python stays 1.13.0.</description>
    </item>

    <item>
      <title>Skills that load only when you need them (v1.14.0 to v1.14.1)</title>
      <link>https://loomcycle.dev/blog/skills-that-load-only-when-you-need-them.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/skills-that-load-only-when-you-need-them.html</guid>
      <pubDate>Sun, 05 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>RFC BA lands on-demand skills. Skill bodies stop bundling into the system prompt at boot; the runtime auto-wires a new Skill tool with list + invoke ops that loads bodies on the first invoke. The agent's skills: field becomes a pattern allowlist (globs like doc/* or writing/*). Empty means allow-all with the Skill tool auto-added; -* means deny-all. Skills are now named with /-grouped segments so a whole domain grants in one line. Breaking: skill_def_scopes is removed; authoring is gated by skills: itself. The skills: allowlist is EXCLUDED from content_sha256 because it's authority-only, not identity-changing. Ephemeral per-run "skills available" note injected on whitelisted agents so the model gets a running-start view without paying the full-body cost. v1.14.1 groups bundled document-agent skills under doc/*, collapses doc-manager's allowlist to skills: [doc/*], and switches standalone document-agent + chat bundles to local-first routing (ollama-local → deepseek → anthropic, autocompact 80→60). Adapters stay 1.13.0 (skills: field is opaque string[] on the wire).</description>
    </item>

    <item>
      <title>Budgets, costs, and encrypted credentials (v1.9.0 to v1.11.1)</title>
      <link>https://loomcycle.dev/blog/budgets-costs-and-encrypted-credentials.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/budgets-costs-and-encrypted-credentials.html</guid>
      <pubDate>Sat, 04 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Three days, five loomcycle releases, four arcs deeply intertwined. Arc 1: a whole-repo hardening pass (v1.9.1) closes 17 findings from a proper security review. Tenant-isolation gaps sealed on four transports (gRPC read+channel RPCs, A2A peer auth, mem9 SSRF + API-key exfil, run cancel + interrupt-resolve). Secret-exposure gaps closed at write-time. Six provider-driver fixes: Anthropic replays the thinking block on tool-use continuations; OpenAI reasoning models use max_completion_tokens; Ollama surfaces in-stream error frames; DeepSeek downgrades thinking-model fallback to deepseek-chat and drops the effort hint; grep re-checks symlink containment before os.Open; runstate delivers events under the write lock. The MCP thin client now transparently self-recovers on both HTTP and stdio (404 / -32001 re-handshake). Arc 2: CredentialDef (RFC AR) — a new substrate Def family for encrypted per-tenant secrets. Envelope AES-256-GCM with a deployment KEK (LOOMCYCLE_SECRET_KEY) and per-tenant DEK derived via HKDF-SHA256; GCM AAD binds each ciphertext to (key_id|tenant|scope|scope_id|name); fail-closed on missing KEK; current+previous KEK rotation with lazy re-encrypt. The credential_defs table (migration 0051) holds sealed ciphertext or an external-backend pointer, never plaintext; excluded from snapshots. Consumers: $cred: substitution in HTTP MCP server headers (per-request resolve; scope precedence agent&gt;user&gt;tenant; a shared pool posts as different users on different runs); tenant + user provider-key override by env-var name (ANTHROPIC_API_KEY at tenant scope shadows operator host key for that tenant's runs; same shape for OpenAI/Gemini/hosted Ollama/DeepSeek/Brave; fail-soft to host default). Arc 3: cost attribution (RFC AV) — a per-call token_usage ledger (migration 0052, both backends) records one row per LLM call with credential_source label (operator/tenant/user), four token buckets, priced cost. Per-call granularity is exact across mid-run provider fallback. GET /v1/_usage returns aggregated reports grouped by any combination of tenant/user/provider/model/source with a whitelisted dimension list; operator-vs-tenant split falls out of the group-by. A Web UI Usage page with group-by chips, from/to window, admin tenant focus, and a summary strip showing operator-bill vs tenant-funded split plus unpriced-calls indicator. Retention: rollup-and-prune sweeper into usage_archive; old-run archiver into runs_archive. gRPC + TypeScript + Python adapter parity in Phase 2c. Arc 4: token budgets (RFC AW). A budget is {tenant, scope, scope_id, window, soft, hard}; scope operator/tenant/user; window calendar-month UTC; most-restrictive-of-the-three-scopes wins. Enforcement: at admission, limits.Check refuses over-hard runs with runner.ErrTokenLimitExceeded (429 on HTTP, ResourceExhausted on gRPC); in flight, recordCallUsage emits an EventLimit event on newly-crossed thresholds. In-memory tracker boot-seeds from the token_usage ledger so a restart doesn't reset counters. EventLimit is a new event type carrying LimitInfo{scope, scope_id, severity, window, used, limit, message} on HTTP SSE, gRPC Run/Continue streams, MCP spawn_run result (Limits alongside Usage), the TS adapter's "limit" event type, the Python adapter's LimitInfo dataclass, and the Web UI's run terminal (amber banner soft / red hard). GET/PUT/DELETE /v1/_limits + gRPC TokenLimit(list|set|delete) share a limits.ResolveWrite confinement helper. A Web UI Limits page with a live month-to-date used column and K/M/G shorthand in the editor. Small wins: routing view (GET /v1/_routing) shows the live provider/model cascade per tier; RFC AU tenant import of Claude Code skills + MCP servers from a .claude/ directory; Path VFS synthesizes implicit-directory entries in one-level ls (fixes the missing rfcs/ folder when listing /loomcycle/). TrueNAS deploy artifacts pinned to 1.11.1.</description>
    </item>

    <item>
      <title>Tenant surfaces, TrueNAS deployment, and thoughts on the wire (v1.6.1 to v1.8.2)</title>
      <link>https://loomcycle.dev/blog/tenant-surfaces-truenas-and-thoughts-on-the-wire.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/tenant-surfaces-truenas-and-thoughts-on-the-wire.html</guid>
      <pubDate>Wed, 01 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Five days, eight loomcycle releases, three arcs. Arc 1: tenant surfaces (RFC AS) — the tenant-operator Web UI lands across v1.6.3-v1.6.7. Library + def-plane /names reads tenant-scoped, Library route gated at substrate:tenant, per-surface nav visibility (v1.6.3), static/bundled agents visible to tenant operators as read-only, schedules surface tenant-scoped, Path/Document browse-by-subject with a topbar picker, audit log tenant-scoped via owning session (v1.6.4), bundled inline top-level skills surface in the Library, Document op=create_document always registers a Path dirent (defaulting /documents/&lt;title&gt;) plus a new op=set_path so documents are never orphaned (v1.6.5), sub-agent session inherits parent's tenant (v1.6.6), Context op=self reports the full principal block + a server block from LOOMCYCLE_PUBLIC_URL (v1.6.7). Load-bearing shape: admin sees all + optional ?tenant= focus, substrate:tenant operator confined by construction, cross-tenant reads return empty (never 403), nav visibility derives from bearer scopes. Arc 2: TrueNAS deployment moved from possible to supported. Distributed across every version: v1.6.2 softens Postgres floor to ≥14 (was pinned 16), moves secrets to env_file, ships chat + chat-local bundles, adds LOOMCYCLE_OLLAMA_LOCAL_NUM_GPU. v1.6.4 fixes SQL-Memory Postgres role (needs CREATEROLE for per-scope roles at runtime). v1.6.7 wires LOOMCYCLE_PUBLIC_URL in compose + INSTALL.md. Arc 3: vision + thoughts on the wire. v1.7.0 lands RFC AT image/vision across all providers (Anthropic native, OpenAI content-array, Gemini inlineData, Ollama images field) + every transport (HTTP + gRPC + MCP + TS + Python adapters bump 1.7.0). v1.7.1 fixes the fallback-target vision gate: re-check SupportsVision inside tryProviderFallback, emit EventFallbackSuppressed with RFC AT §4.4 cite on non-vision target, image never leaks to DeepSeek's raw 400. v1.8.0 wires effort hint to Ollama's think flag (medium/high → think:true) for local reasoning models (qwen3, deepseek-r1); consistent error posture with cloud drivers. v1.8.1 makes CLI validate/agents/doctor honor LOOMCYCLE_PRESETS/CONFIG_DIR/CONFIG_FILES layering, plus a LOOMCYCLE_OLLAMA_DEBUG_THINK diagnostic. v1.8.2 is the two-line root-cause fix: the loop's event switch had no case for EventThinking and no default, so every provider's streamed reasoning trace was silently dropped at the loop and never reached SSE/gRPC/adapters. Fix: case providers.EventThinking: emit(ev). Small wins: v1.6.1 MCP thin-client transparent re-handshake on upstream session expiry (ends the /reload-plugins dance); GET /v1/_models exposes the operator's alias map so a UI can offer aliases in a picker and forks track alias retargets. Adapters unchanged since v1.7.0; server-side patches through v1.8.2.</description>
    </item>

    <item>
      <title>Local LLMs on my TrueNAS, and the frontend I had to build</title>
      <link>https://loomcycle.dev/blog/local-llms-on-truenas-and-the-frontend-i-had-to-build.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/local-llms-on-truenas-and-the-frontend-i-had-to-build.html</guid>
      <pubDate>Sun, 28 Jun 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Field log from upgrading a lab NAS (Intel N100, 16 GB DDR5) into one box that hosts product-test VMs (JobEmber.ai plus a sibling SaaS in stealth pre-release), the loomcycle multi-replica server, and local LLM inference. The constraint framed every other decision: no spare $4500-5500 for an NVIDIA DGX Spark, a Mac Studio with serious unified memory in the same band, Strix Halo (Ryzen AI MAX) starts around EUR 4,000 / $5,000 in Europe and everything is soldered. That reframe ruled out the Spark on price, Strix Halo on price AND rigidity, and a discrete-GPU build on cost-per-model-GB and thermal-envelope grounds. Total build cost: ~EUR 2,100 (Ryzen 7 8700G + 96 GB DDR5 + motherboard + new PSU), roughly half the entry price of the rejected options. The answer was upgrade the existing NAS: AM5 socket (swappable chip), DIMM DDR5 (upgradeable capacity and timing), an APU as the inference engine, and a clean upgrade path for the next-generation Ryzen APU. With that locked, final build: AMD Ryzen 7 8700G with 96 GB of DDR5, doubling as the existing TrueNAS NAS. An APU is not the same as a desktop CPU with integrated graphics; the 8700G's Radeon 780M (12 CUs) is the entry point, the 2-CU iGPUs on regular Ryzen and Intel chips are useless for inference; there is no 12-core or 16-core APU with a strong iGPU in AM5. Memory bandwidth not core count is the bottleneck; DDR5-6000 CL30 EXPO is the AM5 sweet spot (Phoenix controller tops out around 6000-6400 MT/s with two DIMMs); a DDR5-8000 kit downclocks. Kit suffix encodes the profile (Corsair Z = EXPO, C = XMP). Migration: fresh-install plus config restore (don't clone the boot pool); ZFS data pools are portable via zpool import; bigger disks use ZFS replication; anything outside the GUI doesn't transfer. gfx1103 is not officially supported by ROCm; force HSA_OVERRIDE_GFX_VERSION=11.0.2 + OLLAMA_IGPU_ENABLE=1; if rocBLAS errors on TensileLibrary.dat install prebuilt gfx1103 Tensile kernels. Real-workload throughput on this box: gemma4:latest at 13-15 tok/s; qwen3.6:latest at 9-12 tok/s; a smaller 3-4 GB model in the 24-48 tok/s band. The cross-model gap is the memory-bandwidth thesis playing out: more weight bytes per token = proportionally lower throughput, not a compute-limited gap. GTT memory lets the iGPU address tens of gigabytes regardless of the BIOS UMA cap; a 24 GB model runs at 100% GPU on an integrated graphics core with a 128K context window. OLLAMA_FLASH_ATTENTION=1 + OLLAMA_KV_CACHE_TYPE=q8_0 + num_gpu=99 push more layers onto the iGPU. vLLM is for datacenter GPUs and doesn't support the 780M. Thermal surprise: the iGPU shares the same physical package as the CPU cores and there's one temperature sensor; "100% GPU" inference heats the package and shows up as "CPU temperature." A PPT cap at 65 W drops a 90C load to under 60C with no measurable speed loss since inference is memory-bound. The frontend was the next problem: tried Open WebUI for two days and uninstalled it. The chat surface itself is good (clean thread, conversation list, in-thread renderer, keyboard shortcuts). The blockers are underneath: the configuration UI is weird and two days in I still wasn't sure which of several places held the "default model for new chats" setting; providers and models have two unlinked configuration surfaces, and one of them does nothing (the first one I edited was vestigial, the OTHER was the one that mattered); and Open WebUI can't reach the loomcycle tools and primitives I'd built workflows around (Documents, Channels, Interruption + mid-run steering on interactive sessions, per-principal MCP dispatch). So I'm building the chat I wanted on top of the substrate I already use, following the chat-first sequencing in RFC AC. Chat surface ships first: a standalone React + Vite SPA in a new loomboard repo on the published @loomcycle/client; UX modelled on what Open WebUI gets right; each conversation is one loomcycle interactive session (RFC AI); the full tool loop renders inline; live token/throughput/context metrics + a context-compaction button; Interruption answers in place; per-conversation model overrides via a derived AgentDef that doesn't mutate the shared one; reuses existing wire only (no new transports). Board lands next in the same app: kanban over Document + Path, AgentTeam graphs from RFC AP for state transitions, launch publishing plan as the first dogfood loop. In parallel, the two loomcycle pieces I'm head-down on right now are tenant authorization (a real multi-tenant trust boundary across the wire surfaces) and loomcycle running as a TrueNAS-dockerized application; both deserve their own writeup as the next blog topic. The point is that once the hardware worked, the frontend was the bottleneck.</description>
    </item>

    <item>
      <title>Agents and humans on the same chunks. How v1.5.0 made co-authoring possible.</title>
      <link>https://loomcycle.dev/blog/agents-and-humans-on-the-same-chunks.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/agents-and-humans-on-the-same-chunks.html</guid>
      <pubDate>Sat, 18 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>loomcycle v1.5.0 ships three changes that compose into a workflow. RFC AG keys the /v1/_mcp dispatch off the authenticated principal, so user-scoped Memory, Documents, and Path under the MCP-server transport now share the same per-scope SQLite file as the off-run HTTP path (the fix for the cross-transport mismatch where an MCP-created document was invisible in the Web UI). The route opens from substrate:admin to substrate:tenant; the per-tool gate inside the session still hides admin-only meta-tools. RFC AO adds config-declared static (tenant, subject) principals: a new principals: block in loomcycle.yaml binds a stable service identity to a token_env name; the bearer secret lives in .env.local. One declared bearer authenticates both /ui/login AND an MCP thin client at the same (tenant, subject) by construction. RFC AN makes --config repeatable with deep-merge, so bundles stack onto operator config without copy-paste. What it unlocks: the launch plan moved from a flat Markdown file to a Document with 18 publication chunks; status is a queryable typed field; agents and humans edit different chunks concurrently under optimistic-revision concurrency; Channel events fire on every chunk update. The natural agentic shape: scaffold → drafter agent → human review → posted → reporter agent. Additive on the substrate layer; no new wire RPCs; TS + Python adapters unchanged at 1.4.0; Claude Code plugin bumps to v1.5.0. Existing deployments without a principals: block keep working unchanged.</description>
    </item>

    <item>
      <title>Path + Document: a Unix-like VFS and chunked-graph documents (v1.4.0)</title>
      <link>https://loomcycle.dev/blog/path-and-document-primitives.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/path-and-document-primitives.html</guid>
      <pubDate>Sat, 11 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>loomcycle v1.4.0 ships two new substrate primitives. Path (RFC AL) is a Unix-like VFS over Memory + Volumes + Documents using the Linux inode/dirent split: resources keep permanent ids, a dirents row in the runtime store maps (tenant, scope, scope_id, parent_path, name) to resource, one tree spans three resource kinds, renames are cheap dirent updates that never touch the resource. Six ops on the new Path tool (resolve, ls, stat, mkdir as v1 no-op, mv, rm with Linux semantics). Paths reject .. at the boundary; segments [a-zA-Z0-9._-]+, max 64 segments / 1024 chars; tenant-isolated; scope agent/user/tenant matching Memory. A dirent is a name, not an authority grant — the resource's own scope/tenant check still applies. Resources opt in: Memory.set path:, VolumeDef.create mount_at:, Document.create_document path:. SQL Memory stays out of the tree (SELECT doesn't compose with ls). Document (RFC AK Phase 1) is a chunked-graph document where each chunk is a first-class unit with UUID + hierarchy position + optional type + structured fields + graph edges + Markdown body + revision integer. Content/structure split: chunk content (title, body, fields) in Memory keyed by UUID; chunk structure (parent/position/type/status/title/revision + edges + type schemas) in SQL Memory across four tables. Three reasons for the split: different access patterns, dual audit (content + structure), backup composition. 13 ops grouped into document lifecycle, chunk CRUD, edges, query, type defs. Optimistic revision concurrency on update_chunk; atomic deletes in one SQL Memory transaction with bidirectional edge cleanup; delete_chunk refuses the root; move_chunk has a cycle guard; link_chunks validates both endpoints. query_chunks supports structured filters, an under_path Path join, and a validator-gated raw sql: escape hatch through the SQL Memory statement validator. Both primitives are on every transport: POST /v1/_path + POST /v1/_document (HTTP), Path/Document gRPC RPCs riding the existing SubstrateRequest/SubstrateResponse shape, LoomCycle MCP meta-tools path/document, client.path() / client.document() in @loomcycle/client@1.4.0 and python-v1.4.0. Server-side scope/tenant resolution from the authenticated principal, never the wire — an off-run call with scope:"user" keys on the principal's subject, so external UIs and agents interoperate on the same scoped namespace. Both surfaces tenant-confined under ScopeTenant; substrate:admin also satisfies. Bundle semantics for Documents in Path (borrowed from macOS .app): a Document at /docs/foo/v1.0 lists as a directory AND resolves as one resource. Additive — no breaking changes, no wire surface previously consumed. New HTTP endpoints, gRPC RPCs, MCP meta-tools, dirents migration on both backends. Deployments that don't use Path/Document see zero behavior change. Adapters bump to 1.4.0 to add client.path() + client.document(); older adapter code unchanged.</description>
    </item>

    <item>
      <title>Bashbox: in-process shell sandbox for agents. The bench, and three gbash issues (v1.3.0)</title>
      <link>https://loomcycle.dev/blog/bashbox-in-process-shell-sandbox.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/bashbox-in-process-shell-sandbox.html</guid>
      <pubDate>Sat, 04 Jul 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>loomcycle v1.3.0 ships Bashbox (RFC AJ): a new opt-in shell tool backed by gbash (Apache-2.0, pure-Go) that runs scripts in-process. No os/exec, no /bin/sh, no host process spawned. Path resolution stays inside the bound volume because there is no host kernel doing the resolving. The read-only mode is honestly enforceable because the write overlay is in RAM — closing the asymmetry RFC AH left open (rule #7: "Bash refuses read-only volumes rather than ship a guarantee a shell can't keep"). Same input schema as Bash. Same wire events. Adapters unchanged at 1.1.1. Opt-in twice: LOOMCYCLE_BASHBOX_ENABLED=1 per deployment, allowed_tools:[Bashbox] per agent. Stateless per call. Bundle: gbash's coreutils registry plus pure-Go awk and jq via contrib. Unknown commands refuse by default; no shell-out, no host PATH leak. Host-command fallback (RFC AJ §13, operator-only): LOOMCYCLE_BASHBOX_FALLBACK_COMMANDS=git,gh allowlists specific binaries that fall through to the host shell; only those names escape (git status; curl evil.example.com runs git on the host and refuses curl in the sandbox, no smuggling). LOOMCYCLE_BASHBOX_FALLBACK_ALLOWED_ENV scopes credentials to the host child only (the sandbox env never sees them). Requires a read-write volume. Off by default with a loud boot warning when configured. exp10 benches gbash against /bin/sh on a representative coding-agent corpus: 31% slower overall, mixed per-op. Worst case count_funcs (grep -c across the tree) at +310%; one operation faster (total_loc, a wc -l aggregate, at -53%). git_clone only 15% slower because almost all the work is the real git via §13 fallback. One output mismatch: count_all_files returned 1274 vs 1193 because gbash find aborts on a relative symlink and 81 files past it never reach the count. Three findings filed upstream to gbash, all open: #834 find/EvalSymlinks aborts on relative symlinks; #835 grep --include=GLOB missing (21 options defined, no per-file glob filter); #836 xargs -P silently falls back to serial (parsed and stored as maxProcs, but the parallel implementation in xargs.go:884 immediately delegates to the serial path with no warning). Trust posture: rm -rf bounded to the in-RAM overlay; curl doesn't exist in the registry; cd ../../ refused by the workspace boundary; credential files outside the volume don't exist in the gbash workspace. gbash is alpha-tagged and the upstream threat model says "not a hardened sandbox"; loomcycle's outer trust boundary carries the security-critical guarantees, gbash carries the in-process shell semantics. Coverage measured ~97% identical-or-equivalent on a real loomcycle script corpus; the 3% gap drove the §13 fallback design. Why opt-in: trusted-dev deployments stay on Bash (host shell, full PATH, peak throughput); multi-tenant + untrusted-input deployments switch to Bashbox. Both tools coexist; operators pick per agent. The 31% speed penalty matters for tight inner loops, less for the single-shot scripts most agents run. Upstream optimization is pending (gbash dispatch, workspace stat path, find traversal, grep regex cache haven't been profiled). Additive and off-by-default; no breaking changes, no new wire RPCs.</description>
    </item>

    <item>
      <title>SQL Memory for agents. The third facet of the Memory primitive (v1.2.0)</title>
      <link>https://loomcycle.dev/blog/sql-memory-for-agents.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/sql-memory-for-agents.html</guid>
      <pubDate>Tue, 30 Jun 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>loomcycle v1.2.0 ships RFC AA Phases 1 through 3g: SQL Memory. A third facet of the Memory primitive. Authorized agents run arbitrary SQL against a per-scope database the runtime hosts, isolated from the main loomcycle store. Two new ops (sql_exec, sql_query) plus three for transactions (sql_begin, sql_commit, sql_rollback) on the existing Memory tool. Two tiers: sqlite (file-per-scope, statement-allowlist hardened — the default modernc.org/sqlite driver has no authorizer, so the primary defense is a Go-layer parsed-statement validator) and postgres (schema-per-scope, per-scope least-privilege LOGIN role with search_path pinned). Three scopes: durable agent/user (tenant-keyed, persist across runs) plus ephemeral run (one DB per spawn tree, dropped at completion). Default-deny sql_scopes ACL per agent. Per-statement timeout, per-scope byte quota, row cap, full audit (redacted via RFC Z). Phase 3a explicit transactions with runtime-managed cleanup (TTL reaper for abandoned transactions). Phase 3b nested via SAVEPOINT (LIFO, depth-16 cap). Phase 3c vector columns inside agents' own tables on postgres — semantic KNN AND structured filters in one query, with server-side $embed substitution so multi-KB vectors never round-trip through the LLM. Phase 3d + 3f.3 durable-scope GC (TTL + size-budget variants, off by default + lossy by contract). Phase 3e + 3f.2 snapshot integration with per-scope cap. Phase 3g read-only shared schemas — operator-blessed reference data on every scope role's search_path. exp9 demonstrates the shape: prime-stream → SQL table in user scope → channel ping → validator agent on a different run reads the same table, validates each prime, writes verdicts to a second table. Additive + off-by-default — no breaking changes, no new wire RPCs; TS + Python adapters unchanged at 1.1.1.</description>
    </item>

    <item>
      <title>Interactive agentic sessions, now on every adapter (v1.1.1)</title>
      <link>https://loomcycle.dev/blog/interactive-sessions-on-every-adapter.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/interactive-sessions-on-every-adapter.html</guid>
      <pubDate>Sat, 27 Jun 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>loomcycle v1.1.1 ships RFC AI: interactive agentic sessions over gRPC and the TypeScript / Python adapters. A 3rd-party app can now start an interactive run, push operator messages into it mid-flight (steering), survive client disconnect under context.WithoutCancel, and re-attach by run_id from a fresh process or device. The Web UI proved this plumbing over v0.26-v0.30 (park at end_turn, drain a steer queue at the top of each iteration, cross-replica routing via the SteerCoordinator backplane, replay-from-?from_seq + live-tail). It was reachable only through six raw HTTP calls in the embedded UI. The official adapters had a one-shot model. gRPC had a deeper structural gap: the steer.Registry and re-attach tail were owned by the HTTP Server struct, not the transport-shared Connector. Three shared server changes + a thin per-transport surface. S1 makes the re-attach tail self-sufficient (replays the operator's own user_input rows as steer frames with source=replay, so a cold client reconstructs the whole conversation; Web UI de-dupes against optimistic echo). S2 lifts SteerRun + StreamRunEvents + RunEventVisitor onto the Connector so gRPC reaches the same in-process steer registry an HTTP-started run uses; handleRunInput dispatches through SteerRun too. S3 + gRPC: RunInput + StreamRun RPCs, interactive field, AwaitingInput/UserInput Event payloads, source server-stamped (never wire-trusted), tenant opaque-404 preserved, scope gates RunInput=runs:create and StreamRun=runs:read. TypeScript adapter goes 57→61 methods + a high-level InteractiveSession driver that ports the Web UI's useRunStream orchestration. Python adapter goes 40→42 RPCs. Both adapters realign to 1.1.1 (the loomcycle line) so they actually publish together. Reuse over reinvention: the parking, steering, cross-replica routing, and re-attach engines didn't change; only where they're reachable from did. Combined with v1.1.0's Filesystem Volumes, an external product can now create an ephemeral workspace, start a loomcycle agent in interactive mode, drive the conversation through the official adapter, let the user disconnect or switch devices, re-attach by run_id later, and the ephemeral volume auto-purges. Zero loomcycle-specific reverse engineering. The Paca integration itself remains on hold while the maintainer absorbs the multi-agent ensemble shape; the runtime side is no longer the blocker.</description>
    </item>

    <item>
      <title>Filesystem Volumes arrived. Multi-ensemble isolation in one runtime (v1.1.0)</title>
      <link>https://loomcycle.dev/blog/filesystem-volumes-arrive.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/filesystem-volumes-arrive.html</guid>
      <pubDate>Fri, 26 Jun 2026 12:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>loomcycle v1.1.0 ships Filesystem Volumes (RFC AH, Phases 1 through 5). Per-agent ro/rw filesystem roots replace the global jail. Phase 1: a Volume is {name, path, mode: ro|rw}; an AgentDef binds to a named subset, file tools take an optional volume arg, ro/rw is enforced (Bash refuses ro). The load-bearing invariant is spawn confinement: a sub-agent's volume set is parent intersect child, with ro/rw resolving to the more restrictive. The TOCTOU-safe resolveInsideRoot is unchanged; only which root is passed in changes. Phase 2a adds the dynamic VolumeDef substrate, tenant-scoped, runtime-mutable, with a runtime-derived path that never accepts a caller-supplied directory. Names match a strict regex; no slashes, no dots, no path injection. The op set is create/delete/purge, not retire/promote/fork. Phase 2b adds ephemeral run-scoped volumes: create with ephemeral=true, the runtime provisions under _ephemeral/&lt;run_id&gt;/&lt;name&gt;, auto-purges when the top-level run completes (terminally, in any state). Run-tree isolation: the ephemeral set is created fresh per top-level run, inherited by sub-agents, never crosses between runs. Behind four fences for the purge; a singleton sweeper backstops crashed runs; paused runs skipped so snapshot-and-resume keeps its working tree. Phase 3 BREAKING: the legacy jail env vars (LOOMCYCLE_READ_ROOT, WRITE_ROOT, BASH_CWD) are removed. Volumes are now the sole filesystem mechanism. An agent not bound to any volume has no filesystem access. A deploy still setting the retired env vars fails at config-load with a migration hint. Migration is one-line: replace the three env vars with a single default volume in the new volumes: yaml block. Phase 4 ships a Volumes tab in the Web UI; Phase 5 closes cross-transport parity (HTTP, gRPC, MCP, TypeScript adapter, Python adapter all carry the same surface). The killer demo: exp8 ships as a self-contained directory; a dispatcher agent creates an ephemeral volume, git-clones loomcycle into it, fans out 8 reviewer agents via Agent op=parallel_spawn (in-process barrier, no MCP round-trip), each writes findings to Memory, a consolidator merges them into a report on the default volume, the ephemeral volume auto-purges when the dispatcher exits. Contrasts with exp7 (external MCP fan-out, pre-cloned static ro volume, operator-driven barrier). Six PRs (#510 through #515). The Paca conversation that surfaced the multi-ensemble shared-jail problem produced its answer at the runtime level.</description>
    </item>

    <item>
      <title>loomcycle 1.0 is here. Substrate complete. What's next.</title>
      <link>https://loomcycle.dev/blog/v1-0-shipped.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/v1-0-shipped.html</guid>
      <pubDate>Tue, 16 Jun 2026 20:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Two months from a JobEmber.ai VPS that ran out of memory at 3-5 parallel claude-print agents to a feature-complete agentic runtime. v1.0 ships today. Apache-2.0, one ~50 MB Go binary. The substrate is done: six LLM providers plus a deterministic code-js provider, 19 built-in tools with Claude Code parity, MCP on both sides, A2A on both sides, multi-replica HA on Postgres LISTEN/NOTIFY (no Redis dep), pause/snapshot/resume even mid-run and across instances (RFC X both phases), per-run credentials never reach the agent's view of its credentials map, a redact plugin in the run-loop that scrubs secrets before the model sees them, scheduled autonomous runs, signed inbound webhooks, content-addressed forkable AgentDefs with lineage. Production-grade validation: 8-hour stability soak (1.27M circuits, 3.8M agent runs, 100% completion across 468 waves, zero leaks) plus a 133-minute autonomous run on local Qwen3.6:27b through ollama-local after the v0.34.3 to v0.37.0 robustness pass. Seven reproducible experiments in the repo (exp1 to exp7). Paca integration confirmed: direct agreement with the Paca maintainer (Apache-2.0 AI-native Scrum / Trello / ClickUp alternative, 954 stars). The Paca maintainer is implementing the integration over gRPC - Paca's agent service calls loomcycle's gRPC surface directly to spawn runs, stream events, and route per-task agent credentials. Post-v1.0 plans: the context-compress plugin (RFC Z Phase 2, LLMLingua-style); SQL Memory (RFC AA, per-scope SQL databases the runtime hosts for sandboxed agents); capability-based memory interface with mem0 as the first MemoryLayer backend (RFC K). Companion projects (4): loomcycle, n8n-nodes-loomcycle, claude-code-plugin-loomcycle, and the Paca integration in flight. v1.0 is the first portable, durable, hardened version of the substrate. Everything from here is composition.</description>
    </item>

    <item>
      <title>133 minutes on a local Qwen, after four fixes</title>
      <link>https://loomcycle.dev/blog/133-minutes-on-a-local-qwen.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/133-minutes-on-a-local-qwen.html</guid>
      <pubDate>Tue, 16 Jun 2026 18:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Cloud LLMs are wonderful when you have a credit card and a clean API. Local models are a different proposition. Two days of testing loomcycle on a slow Ollama model surfaced four real bugs in a row, plus a fifth after the first four landed. Bug 1 (v0.34.3): the compaction gauge lied for one turn after a successful compaction; lastCtxTokens was only refreshed from a completed provider turn's usage, so Context op=self kept reporting the pre-compaction footprint until the next turn finished. Fixed by refreshing at every compaction site plus stamping the footprint below the compaction block. Bug 2 (v0.34.4, v0.34.5): the Ollama context window was a lie in both directions. Capabilities().MaxContextTokens was hard-coded as 0; the operator-pinned LOOMCYCLE_OLLAMA_LOCAL_NUM_CTX went out as options.num_ctx, capping the window and reporting it, overriding whatever ollama had loaded. qwen3.6:27b trained for 256K, ollama loads at 128K via OLLAMA_CONTEXT_LENGTH, but loomcycle was forcing/reporting 32K. Fix reads the actual loaded context from GET /api/ps once the model is in VRAM (ollama publishes context_length only after load); cached per-model, 5-min TTL, 2s probe timeout, gauge-only, never correctness. Bug 3 (v0.34.4): cloud-shaped 60s time-to-first-byte killed cold local models on disk-load plus prefill. Fix: ollama-local registration gets its own timeout pair, default 300s/300s, configurable via env. Bug 4 (PR #503, v0.37.0): the deep one. A code-reviewer run's auto-compact succeeded but the kept-verbatim tail (20 turns of 5-50KB Read results) was 153.8k tokens, still over the 131k window. Compaction folded older history into a 20.4k summary but the tail was bigger than what fit. Next prefill blew the window; run died. Fix: when the provider reports a window, advance the cut forward, folding the oldest kept-verbatim turns into the summarized span until the kept tail fits ~half the window. Single irreducible over-budget turn is kept, not dropped to empty; estimate-based budget errs toward keeping less, the safe direction for a slow local model's prefill cost. Bug 5 (PR #502, v0.37.0): even with the tail-cap, a single iteration could block ~10 min on a slow model call, and the stale-run sweeper reaped the live run as heartbeat_timeout. OnHeartbeat fired only at iteration START; long prefills exceeded the threshold with no pulse. Fix: a 30s run-lifetime heartbeat ticker that pulses for as long as the run goroutine is alive, in addition to per-iteration. Final run: 133 minutes on Qwen3.6:27b through ollama-local, multiple auto-compactions firing correctly, tail-cap keeping every post-compaction request under the 131k window, heartbeat ticker keeping the run alive through every long prefill, gauge reporting honest used_pct after each compaction. No reaper, no failed prefill, no stale gauge. The agent finished its task. Six minor releases shipped in two days: v0.34.3, v0.34.4, v0.34.5, v0.35.0 (model aliases in tier candidates), v0.36.0 (sandbox introspection), v0.37.0. Each fix small (a goroutine, a 2s probe, a re-stamp); none touch the wire shape. Plus a new docs/CONFIGURATION.md §6b with the slow-local-model recipe and a focused loomcycle.local-interactive.example.yaml.</description>
    </item>

    <item>
      <title>Claude Code orchestrates, loomcycle executes — a real 10-agent code review through MCP fan-out (exp7, v0.33.0)</title>
      <link>https://loomcycle.dev/blog/claude-code-as-operator-loomcycle-as-side-runtime.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/claude-code-as-operator-loomcycle-as-side-runtime.html</guid>
      <pubDate>Fri, 12 Jun 2026 19:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Day seven of the operator-via-MCP series — and the cleanest demonstration yet of the architectural shape loomcycle has been driving toward. Claude Code stays the operator and the conversation surface. loomcycle is the side runtime where the actual multi-agent work runs. Topology: a fresh Claude Code session in the jail git-clones loomcycle, then — using its own .claude/agents/code-reviewer.md and .claude/skills/code-review/SKILL.md as the seed — synthesizes a reviewer agent and a code-review skill for loomcycle. One loomcycle import claude-code --from=work/exp7/.claude --write --skills-dest=$PWD/skills later (the RFC C2 importer that maps the .claude/ shape onto loomcycle's content-addressed Defs — AgentDef + SkillDef), the operator makes one MCP call: spawn_runs(N=10, mode=join) (RFC Y, #464, v0.33.0, shipped today) — fanning 10 reviewers across 10 repo slices (internal/api/http, internal/tools/builtin, internal/providers, internal/store, internal/config, internal/snapshot, internal/scheduler, internal/pause, internal/channels, cmd/loomcycle). The reviewers run concurrently inside loomcycle, each parking findings in the Memory tool under user scope as a shared ledger. One more spawn_run wakes a consolidator that reads the ledger, merges 10 slices into one report, and returns. Result: 10/10 slices, 86 files, 35 issues — 1 Critical + 34 Important. The Critical: internal/channels/scheduler.go:81, a time.AfterFunc closure that can fire before the outer LoadOrStore commits → permanent pendCnt leak under sub-millisecond timer drift. Same-day fixes shipped: #462 + #463 resolved the Critical and most of the Important findings within hours. The Important set surfaced seven structural patterns worth naming: an newID() panic on collision (no retry), a ToolCtx goroutine leak when the call exceeds context, a restored paused-runs status mismatch, a memory-quota check-then-write race, a MaxBytesReader OOM vector via inflated Content-Length, an interactive-goroutine semaphore leak on early return, and a Refresher.Stop() deadlock when the producer holds the same mutex. Three runtime findings surfaced by exp7 itself: Glob abs-path matching falls back to substring on relative roots (matches files outside the allowlist); cross-provider fallback drops reasoning_content when the secondary provider doesn't speak the same field; spawn_runs with N=10 against a single Anthropic-OAuth subscription tripped the per-key rate limit, surfacing the need for an operator-level fan-out throttle. The substrate-shaped path means the 10 reviewers run as real loomcycle agents, with scheduler reach, memory durability, OTEL spans, and per-run credential isolation, while Claude Code stays the human-facing operator. The contract between the two systems is the MCP wire surface — narrow, structured, well-defined. The substrate becomes the place where multi-agent work actually happens; the operator surface stays human-shaped.</description>
    </item>

    <item>
      <title>Context compaction for long-running agents — manual, auto, and the agent asking for it itself (v0.32.0)</title>
      <link>https://loomcycle.dev/blog/context-compaction-for-long-running-agents.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/context-compaction-for-long-running-agents.html</guid>
      <pubDate>Fri, 12 Jun 2026 16:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Yesterday's v0.26→v0.29 interactive terminal made it possible to drive a loomcycle agent for hours from the Web UI — close the tab, come back, the run is still alive. The natural next problem: a multi-hour conversation eventually crowds the model's context window. v0.32.0 ships the answer as three coordinated triggers around one shared summarizer. Manual: a one-click Compact button in the run terminal header that calls POST /v1/runs/{run_id}/compact (scope runs:create), gated to a safe boundary — a live interactive run must be parked at awaiting_input, mid-turn returns 409 (the same iteration-boundary discipline F41 cooperative pause, the steering work's drainSteer, and snapshot all share). Auto: at the top of each iteration the loop checks the previous turn's context footprint against the per-agent autocompact_at_pct (50..95, off by default); if crossed, the loop summarizes inline + replaces, debounced by a +1-iteration guard so the now-smaller request can't loop, and skipped when the window is unknown (e.g. Ollama). Self: a new Context op=compact tool that sets the loop's compact-request flag, summarized at the next safe boundary — useful for a long autonomous run that filled its window without an operator watching. To make self-compact decisions conscious rather than guessed, Context op=self now reports a context object: {used_tokens, max_tokens, used_pct} alongside the resolved compaction settings, so an agent can do "used_pct &gt;= compaction.autocompact_at_pct → call op=compact now". used_tokens is computed as input + cache_read + cache_creation (the same true-prompt-footprint formula the v0.29.0 gauge uses, not just input_tokens which undercounts under prompt caching). Per-agent settings (enabled, target_percentage 10..50, keep_last_n, keep_first, autocompact_at_pct 50..95, model — a cheaper same-provider summary model for cost) round-trip through every AgentDef mirror (mergedDef + applyOverlay + lookup.SubstrateAgentDef + content-identifying so a fork that changes a compaction field mints a new content_sha256; omitempty + normalize-collapse ensures a no-compaction agent hashes byte-identical to pre-feature rows), and through per-run override on POST /v1/runs (MergeCompaction is per-field). Spawn inheritance is the asymmetric design call: compaction settings flow DOWN the spawn tree (unlike memory_scopes / sampling, which are each agent's own) — a parent that knows it needs aggressive compaction wants its fan-out children compacted too. Precedence: per-spawn override on Agent.spawn (and Agent.parallel_spawn) &gt; parent's effective policy &gt; child def's own settings — parent-set wins, child def fills gaps the parent left unset, per-spawn override wins over both, all re-stamped on subCtx so grandchildren inherit recursively. The compacted form is keep-last-N + keep-first, not brutal drop-everything: a CompactionSplit helper snaps the cut to a clean user-turn boundary so a tool_use/tool_results pair is never split (same provider-protocol discipline as the steering work). The pinned task survives verbatim (keep_first preserves the original instruction). The summary replaces the middle. The recent tail (keep_last_n messages) stays verbatim. One summarizer drives all three triggers — loop.Summarize is one-shot, no-tools, target-percentage-parameterized, shared by manual/auto/self; an earlier draft had a separate server.summarizeConversation for the manual path; #461 consolidated to one implementation so a future improvement lands once. Persisted EventContextCompaction marker with trigger/keep_n/keep_first/before/after; replayTranscript rebuilds the [pinned + summary + last-N] form on resume/crash-recovery/continuation, so the durable transcript captures the compacted shape (the full transcript is retained — non-destructive audit); OTEL adds a context.compaction span event. Plus the operator-side polish bundled into v0.32.0: a "Stop" button restyled white-on-dark-red so the destructive cancel reads at a glance, distinct from accent-colored primary actions, and a Claude-Desktop-style composer card for the terminal input (rounded elevated card with footer row showing the live serving model name and "MCP (N)" count derived from mcp__&lt;server&gt;__&lt;tool&gt; calls in the transcript). The clean-boundary discipline this is the third subsystem to insist on: cooperative pause (F41/RFC X), mid-run steering (drainSteer top-of-iteration, never between tool_use and tool_results), and now compaction. The LLM message graph has structural constraints the runtime has to respect; the provider's protocol is part of the runtime's contract, not just the model's preference. Why this matters: long interactive sessions (yesterday's interactive terminal) stop dying at the context wall, autonomous long-running agents (exp6 self-evolving with snapshot/resume) stop hitting context-length-exceeded mid-task, fan-out orchestrators (exp5 agent ensembles) can let their sub-agents inherit the parent's compaction discipline. And the bit that took the most thinking to get right: the agent participates. Context op=self shows the gauge; the prompt instructs the agent to check it; Context op=compact lets the agent act. Compaction stops being something the runtime does to the agent and becomes something the agent does with the runtime.</description>
    </item>

    <item>
      <title>An interactive terminal in the Web UI — steer your agents mid-run, Claude-Code-style (v0.26 → v0.29)</title>
      <link>https://loomcycle.dev/blog/interactive-terminal-web-ui.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/interactive-terminal-web-ui.html</guid>
      <pubDate>Thu, 11 Jun 2026 20:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>loomcycle's Web UI ships a live agent terminal that lets the operator drive a long-running agent Claude-Code-style from the browser. Open /run, pick an agent, type a prompt — the agent streams back into a terminal. Type a new instruction while it's still working and it shows up as the next user turn before the model's next call. Answer its Yes/No questions inline. Close the page, come back two hours later, the run is still alive. Four headline mechanisms shipped at v0.26.0. (1) Mid-run steering: POST /v1/runs/{run_id}/input + a new internal/steer per-run buffered registry (depth 16, mirroring internal/cancel), with a drainSteer hook in the loop that pulls queued messages at the TOP of each iteration — never between a tool_use assistant turn and its tool_results user turn (that orphans the tool_use and 400s the provider; tested in TestRun_DrainSteer_OrderingWithToolResults which fails-before on the naive placement). A new EventSteer SSE event ("steer", not "user_input" — that name was taken by the persisted transcript event) renders the operator's input as a distinct row. (2) Persistent interactive runs that park at end_turn: the loop now emits EventAwaitingInput and parks waiting for operator input rather than terminating, with a heartbeat ticker pulsing OnHeartbeat so the staleness sweeper doesn't reap the idle run. Paired with per-agent unbounded_iterations that lifts the 16-iteration soft-cap for LLM agents (the same exemption code-js providers already get), keeping the 1&lt;&lt;20 hard ceiling as a runaway backstop; cancel becomes the stop. The flag round-trips through every AgentDef mirror (F14/F40 chain) so it survives the content-addressed substrate. (3) Inline interruption answers: when an agent raises a question via the Interruption tool, the terminal renders option buttons (for fixed-set asks) or a free-text answer box right in the live transcript — no need to bounce to a separate /interrupts inbox. The answer goes to the existing resolve endpoint; the parked run wakes on the same open stream. (4) The terminal itself: always-on prompt that routes by state (send routes to steer while running, continue between turns); inline interruption prompt takes precedence; awaiting_input renders "idle — waiting for operator input". v0.26.1 added cross-replica steering via a SteerCoordinator that mirrors the existing CancelCoordinator's shape (route by runs.replica_id to the owning replica, await ack, never re-broadcast — the cancel-storm lesson). v0.27.0 made interactive runs survive a view-switch — pre-fix the loop was bound to the HTTP request context and closing the SSE stream cancelled the request, which cascaded to the loop ctx and terminated the parked run. The fix needed two pieces: detach the loop into a background goroutine under context.WithoutCancel(r.Context()) so it keeps the request's auth principal + tenant values but is not cancelled on client disconnect; and have the handler stream by tailing the persisted event store via a new GetRunEventsSince(runID, afterSeq, limit) method (sqlite + postgres, indexed on events_by_run_seq) instead of writing live to a ResponseWriter that has returned. A new GET /v1/runs/{id}/stream endpoint replays from ?from_seq then live-tails, re-emitting each persisted event as the same SSE frame the live run produced. v0.27.0 also added Context op=self reporting the resolved provider + model (per-iteration so a mid-run provider fallback that swaps opts.Provider/opts.Model in place is reflected truthfully). v0.27.1 + v0.27.2 polished the rendering (auto-scroll, collapsed tool_call, multi-line input, tool_result fold). v0.29.0 (today) completed the polish layer with four pieces. User-message echo: the operator's typed prompt was invisible because the persisted user_input event was filtered from the live SSE tail. Fix is client-side: useRunStream pushes a synthetic user_echo transcript entry on its own sends, seeded in start() with the initial prompt, appended in send() (steer) and sendMessage() (continuation), rendered as "❯ {text}" distinct from a drained "» {text}" steer frame. Context-size gauge in the LiveRunPane header: "ctx 47.2k / 200k (24%)" with an amber > 70% / red > 90% color bar, computing the true prompt footprint as input + cache_read + cache_creation tokens (the prompt-caching honest accounting; input_tokens alone undercounts). When the provider doesn't report a context window (Ollama) the gauge shows just the absolute size without a bar. The plumbing: providers.Usage gained an additive optional MaxContextTokens field stamped on per-iteration EventUsage from opts.Provider.Capabilities().MaxContextTokens; @loomcycle/client 0.26.0 picks up the field. Agent editor sampling controls + advanced JSON/YAML overlay: the agent create/fork modal now has dedicated inputs for temperature/top_p/top_k/frequency_penalty/presence_penalty/seed/stop (blank = unset, "0" = explicit so temperature 0.0 stays distinct from default), and a scoped collapsible JSON/YAML overlay textarea for the long-tail overlay fields without dedicated controls (channels, interruption, *_def_scopes, etc.), shallow-merged over the structured overlay at submit. Deliberately differs from the v0.10.4 whole-overlay catch-all we removed in v0.11.6: system_prompt stays in its own textarea (no newlines-in-JSON), and the box is optional. Soft-reclaim of retired agent names: AgentDefSetRetired is now transactional and clears the agent_def_active pointer in the same transaction when retiring the currently-active def; lookup.resolveDynamic skips a retired active row as defense-in-depth (fixes a latent runtime bug where a retired-but-active def was still served to runs); LibraryEntry surfaces live_version_count + active_retired, and the create modal's name-collision check loosens so a name with no live active version is reclaimable for a fresh create that's allowed to widen the tool ceiling. Three engineering moments worth keeping: the orphaned-tool_use problem (provider message ordering is part of the contract — drain steers at iteration boundaries, never between tool_use → tool_results); the detach-from-request problem (interactive runs need context.WithoutCancel + persistent-store tailing to survive HTTP request lifecycle); the cross-replica problem (already solved for cancel; SteerCoordinator mirrors). What this unlocks: the substrate becomes a development surface, not just a production runtime. The interactive-terminal feature also codifies the "parked run, drained at iteration boundaries, woken by an external event" contract that self-evolving agents (exp6.5 v0.30.0 cross-instance mid-run resume) and agent ensembles (exp5 Channel.await consolidator) both build on. One residual deferred: cross-replica re-attach (a viewer on replica A re-attaching a run owned by replica B) — single-replica re-attach covers the common case.</description>
    </item>

    <item>
      <title>Self-evolving agents — genes that drive real temperature, and an experiment that snapshots mid-run and resumes on another instance (exp6 + exp6.5, v0.25 → v0.30)</title>
      <link>https://loomcycle.dev/blog/self-evolving-agents-genetic-lineage.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/self-evolving-agents-genetic-lineage.html</guid>
      <pubDate>Thu, 11 Jun 2026 18:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>A genetic algorithm over forkable AgentDefs, run in two iterations across five loomcycle releases. exp6 (v0.25.2 static + v0.26.2 fully-dynamic) was prompt-only evolution — three genes (creativity / courage / caution) baked as literal text into each solver's system_prompt and inherited via AgentDef.fork + parent_def_id lineage. exp6.5 closes the experiment cleanly across v0.28.0 → v0.30.0 with all three previously-open gaps now fixed. (1) Per-agent model tunings (#447, v0.28.0) make AgentDef.sampling a real fork-overlay field — content-hashed, round-tripping through mergedDef + applyOverlay + lookup.SubstrateAgentDef, exposed via Context op=self. The creativity gene now sets sampling.temperature = round(creativity/10, 2) — 0.0 focused, 1.0 wild — so a gene mutation actually changes the model's sampling, not just prompt text. Real evolution, not pretend. (2) F41 fixed (#446, v0.28.0, RFC X Phase 1) — cooperative pause now parks in-flight sub-runs at iteration boundaries, gates new POST /v1/runs with HTTP 503 during the quiesce window, and emits a precise warning when a fan-out parent can't yet park. (3) F42 fixed (#456, v0.30.0, RFC X Phase 2) — cross-instance resume of a snapshotted mid-run. ResumePausedRuns reconstructs a paused run's loop from its restored transcript and re-enters loop.Run under the same run_id, fired after a snapshot restore (response reports paused_runs_resumed) and at boot (crash recovery). The killer demo: pause the breeder mid-MUTATE (after gen:1:summary was written but only gen:2:var:0 was seeded — three of four planned forks for gen 2 hadn't happened yet), snapshot capturing paused_runs:1, agent=exp6-breeder, 91 transcript events. Wipe the DB. Boot a fresh loomcycle instance. Restore the file — response: {agent_defs_restored:10, memory_restored:18, evaluations_restored:4, paused_runs_restored:1, paused_runs_resumed:1}. Boot log: "resume: re-dispatched 1 paused run(s); 0 skipped/flagged". The re-dispatched breeder finishes its work autonomously with no external driver — right after restore gen 2 had only var0, with nothing else running the resumed breeder seeded var1-3 on its own, completing the very mutate it was parked in the middle of. The experiment then continues to completion: result:summary {generations:5, best_score:0.91, winner_def_id:def_e7a3, stopped:max_gen}, mean score climbing 0.763 → 0.865, genes converging (creativity 3.0 → 8.8, courage 3.5 → 8.2, caution 7.0 → 4.5). The per-agent temperature gene survived the mid-run restore too — the v0.30.0 winner has sampling.temperature 0.8, set during a forked variant on instance #1 and preserved through snapshot → wipe → restore on instance #2. Cross-instance lineage check, sharper this time: a gen-2 variant forged on the fresh instance by the re-dispatched breeder has parent_def_id resolving to exp6-solver v8 — a gen-1 def that exists on instance #2 only because it was restored from the file. The genetic lineage chain crossed not just the instance boundary but the mid-run instance boundary. The new gen-2 variants weren't just runs the driver spawned afterward; they were the breeder's own work, picked up from where it had been parked, and committed back to the substrate of the new machine. The single failure mode that previously forced "snapshot only at a quiescent boundary" is fixed: pause/snapshot truly anytime. One residual tracked in RFC X Phase 3 (not blocking): parking the fan-out parent itself during parallel_spawn so a mid-SOLVE snapshot also captures the orchestrator — orthogonal to the F42 restore-side fix, doesn't block any real workflow since the breeder's MUTATE phase is where the interesting mid-run state lives anyway (which the v0.30.0 demo captures cleanly). F40 (history, still load-bearing): the AgentDef create/fork overlay was silently dropping the entire *_def_scopes capability family on v0.25.2; a runtime-authored meta-agent couldn't fork. Fixed in v0.26.2 (#436, RFC W) by round-tripping the five *_def_scopes through the substrate, deliberately not part of content_sha256 since ACLs are authority not content. The fix persists through snapshot — the breeder's agent_def_scopes survives the file, so a restored breeder can still fork. exp6.5 relies on this every time it crosses an instance boundary. The engineering lessons worth keeping: an evolutionary substrate needs more than forkable defs — it needs gene-to-runtime mappings that go past prompt text (sampling.temperature is the first); an experiment that fits in a 76 KB file and resumes from any pause point is a different kind of artifact than one that lives in a process; "pause anytime" is a contract that requires both halves of the runtime to participate — the F41 fix made pause wait for in-flight runs to park and gated new spawns, the F42 fix made paused runs come back to life on the other side of a snapshot. Either alone would have been a soft promise; together they make "checkpoint and continue, anytime, anywhere" a real guarantee. What this unlocks: long-running production experiments — prompt evolution, A/B routing of competing agent versions, auto-tuning loops, durable workflows that span days — can be checkpointed at any moment, handed to another developer or another machine, and continue from exactly where they were. The first experiment in the series that needed no new substrate primitives — and now the first portable experiment artifact that survives a mid-run boundary, a DB wipe, and resumes autonomously on a clean machine.</description>
    </item>

    <item>
      <title>Agent ensembles arrive — scheduler-driven fan-out, Channel.await fan-in, and a clock for agents (RFC S, v0.25)</title>
      <link>https://loomcycle.dev/blog/agent-ensembles-arrive.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/agent-ensembles-arrive.html</guid>
      <pubDate>Wed, 10 Jun 2026 16:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Experiment 5 ran end-to-end as a real agent ensemble: five RSS collectors (Hacker News, Wired, Engadget, Ars Technica, TechCrunch) fired in parallel by loomcycle's scheduler every five minutes, each on success pinging a fan-in channel via the scheduler's on_complete hook, a consolidator scheduled one minute after each crossing calling the new Channel.await {channels, mode: at_least, n: 5, wait_ms: 120s} combinator and waking when all five collectors had reported in (or the budget elapsed), URL-deduplication across 25 items, one Telegram digest as output. Three cycles then max_fires=3 auto-retired every collector schedule. Both static and fully-dynamic variants (every entity created at runtime via REST) validated on v0.25.3 with zero workarounds. The v0.25 "agentic-ensemble" release ships RFC S — three primitives the substrate had been missing for ensemble-shaped pipelines. P0: Context op=time gives every agent a wall-clock (closes F34 — pre-RFC-S Context had op=tools/doc/help/self but no op=time, so cycle bucketing required shelling to Bash date which dragged Bash into the collector's allowed_tools surface for no other reason). P1: Channel.await {channels, mode: any|all|at_least, n, wait_ms} is the fan-in combinator across N channels with race / rendezvous / count semantics and a hard deadline (closes F35 — pre-RFC-S Channel.subscribe was single-channel with no AT_LEAST_N / OR / AND combinator; a consolidator had to poll one channel in a loop and count distinct ids, burning max_iterations with no clean timeout; the only native AND-barrier (Agent.parallel_spawn) bypasses the scheduler by coupling N children to one parent run, exactly the coupling an ensemble is supposed to avoid). Symmetric Channel.broadcast publishes one payload to N channels in one call. P2: schedule max_fires retires a def after N successful fires (closes F36 — pre-RFC-S a ScheduledRun fired indefinitely until manually retired; a test ensemble had no way to express "this is bounded"; a forked def can lift the cap with explicit max_fires:0). Three follow-up fixes each surfaced by exp5 itself, all shipped within 24 hours. F37 (#422, v0.25.1, RFC T): the scheduler's on_complete: channel.publish hook was publishing under the run's user scope, ignoring a channel's declared scope: global, so a global fan-in channel's pings landed at user/exp5 and a global Channel.await reader saw 0 messages; the v0.25.0 workaround was scope: user, sort-of-worked because all five collectors shared user_id but a fan-in channel SHOULD be global. The fix is a ResolveChannelScope resolver wired into the scheduler so the publish hook uses the channel's declared scope, not the run's. F38 (#424, v0.25.2, RFC U): the fully-dynamic exp5 variant (every entity at runtime) failed with "unknown agent: exp5-collector" — the scheduler fire path was resolving agents from the yaml-static registry only, not the AgentDef substrate store; same shape as the F30 webhook-spawn fix from exp4. Fix: scheduled runs now stamp the def's tenant on spawn, agent resolves against both registries. F39 (#426, v0.25.3, RFC V): the fully-dynamic Telegram leg got HTTP 404 because the bot received the literal string ${LOOMCYCLE_TELEGRAM_BOT_TOKEN} as its API token — dynamic stdio MCPServerDef env values weren't being interpolated against the runtime's env at spawn time, only at YAML-load (which the dynamic path bypasses by design). Same envelope-of-references pattern hit in MCPServerDef header expansion back at v0.18. Fix: expand ${ENV} references in the dynamic stdio MCP def's env map at spawn time, using the same prefix-allowlisted resolver the rest of the substrate uses. The agent-ensemble term: a multi-agent system is not an ensemble. An ensemble needs primitives that survive the loss of any single agent's run. Each agent has its own run (independent run_id, independent lifecycle, billed and OTEL-spanned independently). Coordination flows through the substrate (channels, memory, scheduling, Channel.await), not through call stacks. The ensemble outlives any single agent. The ensemble can be authored declaratively (data, not control flow). Companion change: every prior experiment (exp1-exp4) now ships as a self-contained directory under loomcycle/examples — each carries its own loomcycle.yaml, run.sh launcher, .env.local.example template, and reproducible README, routing Anthropic-OAuth-primary with a DeepSeek fallback so an operator can clone and exercise the substrate in minutes. exp5 joins next. The substrate is now ensemble-shaped, not just agent-shaped.</description>
    </item>

    <item>
      <title>The day the reviewer agent inlined the Gitea token in a Bash command — and v0.23.4 redacted it anyway</title>
      <link>https://loomcycle.dev/blog/the-day-the-reviewer-agent-inlined-the-token.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/the-day-the-reviewer-agent-inlined-the-token.html</guid>
      <pubDate>Mon, 08 Jun 2026 16:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Day four of the operator-via-MCP experiment series. Real Gitea on the tailnet, real third-party MCP (gitea-mcp, 53 tools), real Telegram bot. Closed-loop dev workflow runs end-to-end un-bridged: the coder agent opens a real PR, a Gitea pull_request webhook auto-spawns the reviewer with the full signed payload, the reviewer reviews + merges, a second webhook spawns the advisor, Telegram lights up. By v0.23.5 every entity in the loop can be runtime-created (agents, MCP server, channels, webhooks) — no static yaml. The headline moment came mid-merge: the reviewer agent inlined the resolved GITEA_TOKEN literally in a Bash command (GITEA_TOKEN="..." git -c http.extraheader=...) and then ran `env` for good measure. The token is now in two distinct tool-call records (input + result text). Pre-v0.23.4 this would have persisted to the SQLite transcript store as plaintext, retrievable by anyone who grabbed the DB file. v0.23.4 (#407) ships value-based secret redaction at rest: the runtime resolves the values of every LOOMCYCLE_* env var at boot, scans persistence-bound payloads (tool-call input + result text) for substring matches, and replaces every occurrence with [redacted:&lt;env-var-name&gt;]. The match is value-based, so an agent that assigns the token to a differently-named shell var (GITEA_TOKEN=... instead of LOOMCYCLE_GITEA_TOKEN) is still caught. The replacement preserves the env-var NAME for debuggability — the operator can see WHICH secret got used where without seeing the value. Whole-DB scan after reproducing the leak under v0.23.4 with the secret pulled from .env.local + piped through strings | grep -f: 0 literal token hits, 0 webhook-secret hits, 32 env-var-name references. Plus three supporting fixes that completed the fully-dynamic version of the workflow: F30 (#403) webhook → dynamic agent — WebhookDef now stamps TenantID from the run identity (mirroring AgentDef), so a runtime webhook resolves a runtime agent under the same tenant (v0.23.2 failed with rejected_spawn_setup: unknown agent). F31 (#405) gated dynamic stdio MCP — LOOMCYCLE_MCP_ALLOW_DYNAMIC_STDIO=1 opt-in flag permits POST /v1/_mcpserverdef with transport:stdio + command/args/env; off by default. The token-safe pattern uses a tiny shell wrapper (gitea-mcp-stdio.sh: env GITEA_ACCESS_TOKEN="$LOOMCYCLE_GITEA_TOKEN" exec gitea-mcp -t stdio) so the secret flows env → env → env, never entering a tool argument. F33 (#409) dynamic MCP tools advertised at run start — a single-purpose MCP-only notifier agent (like the advisor with allowed_tools ["mcp__telegram-dyn__*"] only) can now enter tool-calling mode without needing Context as a discovery crutch. Two webhook discoverability footguns from the v0.23.0 incarnation of exp4 also fixed in v0.23.1/v0.23.2: F23 (#385) HMAC secret allowlist three-rule model so zero allowlist config needed for the common case, F28 (#387) default the webhook payload to the raw signed body when no `goal` mapping is configured. The unifying lesson: store names, never values — and enforce it value-based, so the agent's typo doesn't undo your discipline. The residual caveat worth naming: the Bash process the agent spawns still has the real environment, so at runtime the secret is in process memory; the redaction is "value-at-rest" (the persistence boundary), not "value-out-of-process" (a separate, larger sandbox-design question).</description>
    </item>

    <item>
      <title>Multi-agent refine loop, 0.92 → 0.98 in 5 hops — and the silent default-deny that almost made it look like the agents weren't talking</title>
      <link>https://loomcycle.dev/blog/multi-agent-refine-loop-and-the-silent-default-deny.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/multi-agent-refine-loop-and-the-silent-default-deny.html</guid>
      <pubDate>Sun, 07 Jun 2026 16:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Day three of the operator-via-MCP series. Three agents iterating "What is recursion?" over Channels + Memory + Evaluation — answerer, evaluator, aggregator. Five hops. Scores climbed 0.92 → 0.98. Winner: "A mirror facing a mirror — each reflection a smaller twin of the last, until there's nothing left to reflect." A later sonnet-driven re-run pushed it further to "Mirrors facing mirrors, until one touches bedrock" at 1.0 laconic + 0.98 metaphor. The convergence is the easy part. The interesting part is everything we had to fix to make the loop run cleanly. Five bugs the runtime should have warned about at boot: F21 — every Memory operation was silently refused because the agents lacked memory_scopes. Default-deny is correct (operators must explicitly grant scopes); default-silent-deny is a footgun (the runtime knew at config-load this combination was wrong, didn't say so). #389 ships boot-warns for the family: Memory in allowed_tools + empty memory_scopes, Evaluation + empty evaluation_scopes, Channel + empty channels, Interruption + missing interruption.enabled. F18 — spawn_run user_id was silently overridden to "default" under the legacy LOOMCYCLE_AUTH_TOKEN deployment path because RFC L's applyPrincipal mints a fixed placeholder principal (Subject:"default", Legacy:true) and the principal is authoritative over the wire. Channel and Memory ops scoped to "default" not "exp3"; the orchestrator's kickoff to scope_id=exp3 never reached the answerer. #388 ships: when the principal is Legacy, honor the wire user_id (fall back to placeholder only when omitted); real OperatorTokenDef principals keep the strict override. F20 — channeldef CRUD lived only in REST; MCP had publish/subscribe/peek/ack/list but no channeldef. A Claude Code session managing the loomcycle substrate through MCP couldn't create channels. Plus yaml-declared channels were immutable through REST (409 channel_yaml_immutable); buffered messages from a failed earlier run had to be cleared via raw DB delete. #395 adds the meta-tool with create/delete plus #401 adds a purge op that clears channel_messages without a restart. F22 — Channel.subscribe wait_ms was silently truncated by LOOMCYCLE_CHANNELS_LONGPOLL_CAP_MS (default 30s); agents expecting to long-poll for a minute burned their max_iterations budget re-subscribing every 30 seconds. #390 warns at load and at runtime when the cap truncates the request. F29 — runtime-substrate channels were administered correctly but couldn't be used for pub/sub: the per-run channel policy was built from cfg.Channels (yaml) only, never the runtime channel store. So in the fully-dynamic configuration (every entity at runtime, no yaml) the loop couldn't run. #404 merges the runtime channel store with the yaml channels: block in both the per-run policy and the admin publish/subscribe wire check. The dynamic re-run on v0.23.3 (three agents created at runtime via POST /v1/_agentdef, three channels via POST /v1/_channels, kickoff admin-published to a runtime channel) completes the loop cleanly — scores 0.80 → 0.95 across 5 hops, aggregator picks the true max, memory retired. One prompt-side caveat from the dynamic re-run worth naming: read ordering after the done-signal. The aggregator called Evaluation.list_for_run BEFORE the hop-5 submit landed (got 4 evals + null answer for hop 5), then after the done-signal reused that stale snapshot instead of re-reading. The substrate served the data correctly; the agent's prompt forgot to refresh after the gate. Documentation fix, not a runtime fix. Three disciplines fall out: boot-warn on every capability-tool/gate inconsistency, warn on every silent truncation, authoring surface parity across MCP / HTTP / gRPC / TS.</description>
    </item>

    <item>
      <title>The MCP server wedged the IDE on a list — head-of-line blocking, and why killing the process was the only release (RFC O/P/R)</title>
      <link>https://loomcycle.dev/blog/the-mcp-server-wedged-the-ide-on-a-list.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/the-mcp-server-wedged-the-ide-on-a-list.html</guid>
      <pubDate>Sat, 06 Jun 2026 16:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Day two of the operator-via-MCP series. Mid-experiment, the operator's IDE (a fresh Claude Code session driving loomcycle through the plugin's stdio MCP server) hung on a list_runs tool confirmation. The user approved the call. Nothing happened. The only way out was to kill the loomcycle mcp process from another terminal. Source-reading v0.22.0's internal/api/mcp/server.go found a single load-bearing footnote (lines 102-115): "Frames are dispatched SEQUENTIALLY on a single goroutine. Concurrent tools/call is a v0.9.x optimisation — not implemented." The comment was honest; it was also wrong about the priority. Combined with an unbounded spawn_run handler (no per-call timeout), one slow run blocked every subsequent frame in the OS pipe behind it — even a cheap list_runs, even a cancel_run. Classic head-of-line blocking on a single-consumer stream. The list was the victim, not the cause. Three amplifiers turned "slow" into "wedged for an hour": F15 (cross-runtime interruption wake) made an interruption-held run block to the 1-hour interruption.default_timeout_ms; provider outages (the day this happened coincided with an Opus 4.7/4.8 incident on Anthropic's side) stalled spawn_run on retries; F16 accumulated SSE clients + heartbeating runs added resource pressure on top. Why killing the process was the only release: ctx cancellation is polled only between frames, so cancelling the parent context doesn't preempt an in-flight handler. A cancel_run frame would itself be HOL-blocked behind the occupier. SIGTERM on the loomcycle mcp PID ends the process; stdin closes; the wedged call dies with it; the IDE sees the MCP transport drop and reconnects. Three coordinated PRs in v0.23.0 close the failure class at three levels: RFC O (#377) makes stdio dispatch concurrent on bounded goroutines (the writeMu for stdout framing already existed, only used by the notification path; now it serializes every response). Independent tool calls run concurrently; initialize/initialized stay sequential. The HOL class is gone. RFC P (#380) wraps spawn_run in a transport-level context.WithTimeout, configurable per operator. A provider stall or unkillable run no longer keeps the handler alive forever. Composed with RFC O, the bound is hard. RFC R (#381) ships the thin-client topology: loomcycle mcp --upstream &lt;runtime-url&gt; launches a stdio MCP server that proxies to a single full runtime over HTTP/gRPC. Every tool call lands on the one runtime that owns the in-process bus; the cross-process bus-Notify problem dissolves (interruption_resolve from a thin client hits the same in-process bus the blocking ask waits on). F15 stops being reachable in normal use. Breaking change in the same release (#383): loomcycle mcp --no-http is removed — the pattern that needed it (two full runtimes side-by-side with HTTP suppressed on one) is the pattern that caused F15. A real fix for the original code path — durable cross-runtime wake via the _system/interrupts/resolved channel — shipped two days later (#400) for multi-host HA topologies that genuinely need multiple full runtimes. The thin-client pattern stays the recommended default. None of the three PRs would have been sufficient alone: concurrent dispatch without timeout still allows a goroutine slot to be tied up indefinitely; timeout without concurrent dispatch still produces "list hung for 5 minutes" before the timeout fires; thin-client without the other two still HOL-blocks a single transport on a single runtime. The three together remove the failure class at three different levels. The engineering lesson: a "we'll fix it later" comment on a load-bearing concurrency property is a P1 bug, not a roadmap item. The runtime documentation said the limit; the operators' code didn't read the documentation. The first real workload to touch the limit looked like complete system failure.</description>
    </item>

    <item>
      <title>We drove a fresh Claude Code session through MCP — and the first experiment found a DeepSeek bug we'd shipped without noticing</title>
      <link>https://loomcycle.dev/blog/we-drove-claude-code-as-the-operator-and-found-a-deepseek-bug.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/we-drove-claude-code-as-the-operator-and-found-a-deepseek-bug.html</guid>
      <pubDate>Fri, 05 Jun 2026 16:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>To pressure-test loomcycle from the outside, we set up an isolated sandbox, installed the brew binary (v0.22.0), and drove it through a fresh Claude Code session as the operator — talking to loomcycle over MCP, with no internal shortcuts and no API access a real third party wouldn't have. Every experiment in the four-experiment series was designed in advance by us; every step was executed by Claude through the same operator-facing tool surface (spawn_run, list_agents, agentdef, interruption_resolve, ...) a community operator would see. The point of running experiments through an agent rather than a hand-written script is that the agent surfaces gaps a script wouldn't — a script does what the author wrote; an agent does what the system tells it is possible, and an honest agent surfaces every moment where the system's docs, error messages, and capability gates don't line up. This first post covers experiments 1 (tool access) and 2 (interruption). Experiment 1: a generic code-guru agent with allowed_tools [Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch] on deepseek-v4-pro is asked to write the first-100-primes program, save it, run it, verify. Run 1 died at Bash{mkdir -p exp1} with DeepSeek 400 "messages[3]: missing field content" — mkdir on success returns empty stdout, loomcycle's openai-compat adapter was serializing the tool-result message with no `content` field when the content was empty, and DeepSeek's API requires it (every other provider tolerated the omission, so the bug had been invisible against Anthropic/OpenAI/Gemini). F10 — fixed in v0.23.0 (#379, RFC Q): always serialize content on tool messages, defaulting to empty string when actually empty. Run 2 passed cleanly; independent verification confirmed exactly the first 100 primes (0 mismatches, first/last 2/541). Plus F13 surfaced: Write resolves relative paths against process CWD, not LOOMCYCLE_WRITE_ROOT — with the server launched from project root, Write exp1/... split from Bash's BASH_CWD=./work. Operationally fixed by launching loomcycle from the jail directory. Experiment 2: yesno-agent asks "Approve the deployment?" [Yes,No], blocks until resolved, branches on the answer. Both passes worked end-to-end (Claude self-resolves, then marshals to human via Claude Code UI; human answers; agent wakes and branches correctly). But the first resolve attempt — via the plugin's MCP server — left the run blocked despite flipping the DB row to "resolved." F15: Interruption.ask blocks on channels.Bus.Wait, an in-process bus; the resolve handler calls bus.Notify on whichever process received the resolve. The plugin MCP and the HTTP server were two separate processes — same SQLite store, separate in-process buses. The plugin's bus.Notify woke no one. v0.23.0's thin-client topology (RFC R, #381) dissolves this: loomcycle mcp --upstream proxies to a single full runtime, so the cross-runtime pattern that triggered F15 stops arising in normal use. The breaking change (#383): loomcycle mcp --no-http removed. The engineering lesson: provider-adapter correctness is load-bearing in a way unit tests miss. The F10 bug had been live in internal/providers/openai/serialize.go for months. Unit tests against the OpenAI API (which tolerates the missing field) passed; integration against DeepSeek (which doesn't) had never been exercised on a Bash that returns empty stdout. Hand-written integration tests would have had to know to send empty content deliberately; an agent doing mkdir -p sends it accidentally on the first call. Every provider in provider_priority now passes an end-to-end smoke test where the agent's first tool call returns empty stdout.</description>
    </item>

    <item>
      <title>Collapsing four hallucinating LLM orchestrators into zero tokens — and the two bugs the migration found</title>
      <link>https://loomcycle.dev/blog/collapsing-llm-orchestrators-into-zero-tokens.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/collapsing-llm-orchestrators-into-zero-tokens.html</guid>
      <pubDate>Thu, 04 Jun 2026 16:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>JobEmber's agentic pipeline had four batch orchestrators — each taking a list of N items, fanning out N parallel LLM worker agents, optionally reducing the workers' outputs. Three lived in TypeScript via Promise.allSettled with N orphan run-ids and no cascading cancel; one had been lifted into the runtime as an LLM agent with a careful system prompt instructing it to "fire all N spawns in ONE iteration." The orchestration work itself was deterministic — partition a list of job sites round-robin into N slices, chunk a list of matches into batches, wrap each item in a worker prompt, spawn one child per slice, collect results — yet job-search-batch was burning ~8,000 tokens per run to do round-robin partitioning, and the weak-tier model occasionally violated its own prompt and serialized the workers it was supposed to fan out. v0.20.0's inline code_body ingestion (the previous post) made the fix viable: replace all four orchestrators with deterministic code-js agents whose bodies ride through AgentDef like any other agent attribute. Result: zero tokens for the orchestration layer, replay-deterministic by construction (pure function of input.metadata + recorded tool results), one run-id to monitor and cancel as a unit (cancel cascades to children via loomcycle's multi-replica cancel primitive), Scheduler-fireable because the orchestrators now live in the runtime, and the "FIRE ALL N SPAWNS" hallucination goes away because JS doesn't have an opinion about how to dispatch — it calls Agent.parallel_spawn every time. The four orchestrators converted: employer-research-batch (was TS Promise.allSettled, now code-js fire-and-forget — children self-ingest via postResearchIngest); cv-cl-batch (was TS, now code-js fire-and-forget — children self-ingest via patchApplication); ats-filter-batch (was TS chunked map, now code-js map-reduce reducer — chunks matches, fans out filter workers, robustly parses each child's output via an ES5 port of parseAgentJSON, flattens + dedups verdicts); job-search-batch (the LLM agent, ~8K tokens/run → code-js — round-robin partition of jobSites[] into N slices, spawn one job-searcher per slice). Migration surfaced two latent loomcycle bugs, both shipped in v0.21.0. Bug #1 (PR #359): code-js wall-clock budget was 120 seconds, sized for CPU-bound JS that runs to completion in goja without I/O — but a fan-out orchestrator parks for minutes in Agent.parallel_spawn awaiting LLM children. The wall-clock budget keeps ticking; resume turns started already over-budget; the runtime interrupted the next interruptible bytecode (typically parseBatch or whatever was running when the deadline expired); the error message named that line, blaming entirely innocent code. Worse, interruptWatch's TIMER branch fired rt.Interrupt(context.DeadlineExceeded) WITHOUT cancelling the parent ctx, so classifyRunErr (which only special-cased ctx.Err()!=nil) fell to the default and emitted code_agent_threw instead of a distinct timeout error. Fix: replayState.timedOut atomic set before the interrupt; classifyRunErr emits a distinct code_agent_timeout class stating the budget and pointing at the override knobs, separate from code_agent_cancelled (ctx cancel) and code_agent_threw (real JS throw). Per-agent run_timeout_seconds on AgentDef (operational, not in content_sha256, mirrors retry_attempts) and per-run run_timeout_seconds on /v1/runs + /v1/sessions/{id}/messages. pickRunTimeout resolves per-run > per-agent > global default. Threaded through all four loop.Run sites including runSubAgent (caught in self-review — the one site that almost shipped without the override). JobEmber's batchRunTimeoutSeconds() scales the budget with the fan-out width (ceil(N/concurrency) waves, clamped to [180s, 1800s]). Bug #2 (PR #366): the replay model rebuilds the goja runtime each turn and re-converts the run's metadata (a Go map[string]any) into input.metadata via rt.ToValue. Go deliberately randomizes map iteration order per access. SAME metadata → JS object with DIFFERENT key order on each turn. An agent that does JSON.stringify(input.metadata.matches) into a tool_use input emitted byte-different bytes turn-1 vs replay, tripping a spurious code_agent_replay_divergence on tool call #0. Observed exactly once, in ats-filter-batch (the only orchestrator that serializes objects in JS — the other three pass pre-built prompt strings through metadata). LOOMCYCLE_CODE_AGENTS_DETERMINISTIC=1 does NOT fix this; it pins only the RNG seed + clock anchor, not Go-map key order. Fix: stableJSValue() recursively materializes every Go map as a JS object with sorted keys; arrays keep their order. JS objects are insertion-ordered, so sorted insertion yields sorted iteration AND sorted JSON.stringify. Reserved-key precedence (user_id/agent) unchanged. JobEmber had to ship a defensive fixed-key normalizeMatch workaround before the loomcycle fix landed; deleting normalizeMatch after #366 is itself the soundness test of the fix — replay staying divergence-free without the workaround is the test that the patch is correct. Why both bugs were hidden behind the LLM-orchestrator path: an LLM agent doesn't park in Agent.parallel_spawn synchronously (tool calls are issued by the model, the loop's outer ctx covers wall-clock — the code-js inner budget never fired), and an LLM agent never serializes input.metadata in JS (it has no JS at all). Both bugs were implied by the deployment shape the LLM-orchestrator path had been hiding. The pattern worth taking forward: if a step in your agentic pipeline can be expressed as a 30-line deterministic function, it should not be an LLM agent. Routing, partitioning, chunking, reducing, formatting — these are not language tasks. Asking an LLM to do them costs tokens, introduces non-determinism, and occasionally surfaces in a model "creatively" reinterpreting its instructions.</description>
    </item>

    <item>
      <title>Code agents without a host filesystem — JS bodies through the substrate (loomcycle v0.19.0 + v0.20.0)</title>
      <link>https://loomcycle.dev/blog/code-agents-without-a-host-filesystem.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/code-agents-without-a-host-filesystem.html</guid>
      <pubDate>Wed, 03 Jun 2026 16:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Code-as-agent (RFC J, v0.16.0) shipped operator JavaScript as a first-class agent via goja, with the JS body read from agent_code/&lt;name&gt;/index.js on the loomcycle sidecar's disk. Every OTHER AgentDef attribute (system_prompt, allowed_tools, tier, model) had been content-addressed, versioned, and snapshot-portable through the dynamic substrate since v0.9. The JS body alone wasn't — and that host-FS dependency didn't survive three deployment shapes real operators were already running: pure cloud (no host filesystem to bind into a loomcycle container), container orchestration where bind-mounting the host disk breaks the portability story, and n8n interactive where workflow authors define agents at design time and never touch the sidecar's disk. v0.19.0 (PR #349 + follow-up #350) threads inline code_body through AgentDef as a hash-significant content field. Empty body omitempty's out of canonicalization, so every existing non-code AgentDef hashes byte-for-byte as before — zero upgrade churn. Inline code is content: two bodies hash differently, verify/dedup stays correct. The field rides through four mirrors (config.AgentDef.Code yaml:code, mergedDef, lookup.SubstrateAgentDef + ToConfigDef, AgentContent) and providers.RunMeta.CodeBody from the loop to the provider, with the leaf providers package keeping its no-store/no-tools invariant. All four run-creation paths (RunOnce, /v1/runs, /v1/sessions messages-continuation, runSubAgent) populate it so sub-agents inherit the body identically. Provider prefers inline body over filesystem; the compiler cache re-keys by content_sha256 (not by agent name) so a new AgentDef version with new JS compiles fresh instead of serving a stale program. Gated via the existing LOOMCYCLE_CODE_AGENTS_ENABLED switch; dedicated 256 KB cap (LOOMCYCLE_AGENT_DEF_MAX_CODE_BYTES); pure codejs.Validate as single source of truth for parse-compile (cycle-free). Forking a filesystem-backed code agent into the substrate is intentionally refused with a hint to supply inline code_body — auto-materialising the disk body would re-introduce the FS dependency we're removing. Three review-fixes in the same PR: (1) boot-fatal validation had only known about the filesystem, so the headline no-FS-bind case was failing log.Fatalf at boot; now validates via codejs.Validate when def.Code is set. (2) Per-turn disk read regression on the FS path — the compiler refactor for inline bodies accidentally dropped load()'s by-name early check, so every replay turn of a code-agent run was re-reading + re-hashing index.js; restored a by-name fsCache for the filesystem path while keeping the inline path content-hash-keyed (a by-name cache there would serve a stale program after promotion). (3) False dedup contract — AgentDef.execCreate had no content-addressed dedup unlike MCPServerDef, so the TS ensureCodeAgent &quot;changed&quot; flag was always true and its byte-identical re-register no-op contract was a lie; added the same idempotent-create dedup MCPServerDef got in #343. PR #350 closes a three-way hash drift the v0.19 work surfaced: FromYAMLAgent omitted CodeBody, agents.Agent had no Code field, so the .md-discovery path and the loomcycle hash agent CLI computed content_sha256 WITHOUT the body — three producers, three definitions of content. Plus mergeAgentDef dropped override.Code on the .md+yaml merge path. The fix is the conceptual one: a content-addressed hash has ONE definition; every producer converges on it. v0.20.0 lights up the Web UI (PR #351 — Library renders code_body as a monospace block alongside system_prompt; create/fork modal grows a code-body textarea shown only when provider==code-js; validateLocal requires a body for code-js agents; the false &quot;unreachable/handshake failed&quot; message on MCP servers with empty discovered_tools is reframed to admit lazy registration is normal). PR #352 closes a sibling MCPServerDef asymmetry on the same lesson — discovery now runs at ingestion (create + fork run tools/list and fold the result into the same version — no v2, no separate manual rediscover step). Best-effort against unreachable peers (bounded by the existing 30s budget), promote-gated, size-guarded (metadata-only if discovered tools would push past MaxDefinitionBytes), opt-out with discover:false. discovered_tools is not part of content_sha256 so dedup is unaffected. PR #353 bumps @loomcycle/client to 0.20.0: ensureMcpServer reads discoveredToolCount straight from the create response — no separate rediscover round-trip on first registration; rediscover becomes an explicit force-refresh escape hatch. Same engineering lesson as yesterday's MCPServerDef static-vs-dynamic asymmetry-class post: every substrate primitive has both a yaml-loaded and a dynamically-created path; every seam must work the same on both; whichever path is the less-trafficked one will silently rot.</description>
    </item>

    <item>
      <title>We inverted a startup race — and found four static-vs-dynamic asymmetries to close (loomcycle v0.18.0)</title>
      <link>https://loomcycle.dev/blog/inverting-a-startup-race-found-four-asymmetries.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/inverting-a-startup-race-found-four-asymmetries.html</guid>
      <pubDate>Tue, 02 Jun 2026 22:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>A live failure on 2026-06-02 in JobEmber's cv/cl-adapter agent traced back to a static mcp_servers.jobs block in loomcycle.yaml that created a chicken-or-egg race between the loomcycle sidecar and the MCP-providing web service. loomcycle had been running tools/list at its own boot — before jobs-search-web was up — and the discovery handshake fell back to an unresolved env token, 401'd, and the mcp__jobs__* catalog stayed empty. Agents then narrated tool calls as text instead of executing them. The fix direction was to invert the dependency: the MCP-providing service registers its own MCPServerDef dynamically at its startup, when the handshake will actually succeed (the substrate pattern every other Def primitive uses). The inversion looked like one PR. It turned out to be four distinct static-vs-dynamic asymmetries the substrate had been silently hiding because every previous consumer stuck to the static path. PR #340: dynamic create only checked the public host allowlist (LOOMCYCLE_HTTP_HOST_ALLOWLIST), not the private one (LOOMCYCLE_HTTP_PRIVATE_HOST_ALLOWLIST), so a self-hosted loopback URL was refused fail-soft. PR #341 + #345: the lazy tool resolver consulted only the static-yaml serverConfig map, while the pool's connection-building callback consulted the DynamicRegistry — two different sources, and "tool not found: mcp__jobs__postResearchIngest" was the symptom; the cleanup migrated to the shared lookup.MCPServer resolver that Webhook/MemoryBackend/A2A/Agent/Skill already used (it had been an orphan with zero callers). PR #343 + #344: every consumer restart minted a new MCPServerDef version because create never compared content_sha256 against the active row; one jobs server's lineage had grown to 19 versions in days. Server-side dedup landed (idempotent create on matching content; idempotent rediscover on unchanged discovered_tools); typed ensureMcpServer + mcpServerDefVerify sugar in @loomcycle/client 0.18.0 (51 methods total). PR #348 — the most subtle: yaml-load runs expandEnv on the whole document, so a header like Bearer ${run.credentials.jobs:-${LOOMCYCLE_JOBS_SEARCH_API_TOKEN}} arrives at the substrate with the inner ${LOOMCYCLE_*} already flattened. Dynamic MCPServerDef registration bypasses config.Load entirely, storing the nested template verbatim. The request-time substituter's lazy .*? fallback regex (whose safety comment explicitly depends on inner vars being pre-resolved) then truncates on the inner } and loomcycle sends literal Bearer ${LOOMCYCLE_JOBS_SEARCH_API_TOKEN} upstream → hard 401. Fix: add config.ExpandEnv (exported thin wrapper over the existing prefix-allowlisted expandEnv) and call it on the operator-authored connection fields inside MCPServerDef.buildDefinition — the shared chokepoint for both create and fork. Two bonus close-outs on the same asymmetry class: PR #347 (dynamic tools were callable via the lazy resolver after #341 but never advertised in the per-run model catalog — same asymmetry, advertising side); PR #346 (static yaml schedules never auto-fired because they never seeded the sweeper's substrate-only due-query — added BootstrapStaticSchedules, idempotent + fork-respecting). The unifying lesson worth taking forward: every substrate primitive now has both a yaml-loaded and a dynamically-created path; each seam between substrate and runtime must work the same on both — host allowlists, runtime resolution, tool advertising, content-addressed dedup, env expansion, sweeper bootstrap — or the side nobody exercises silently rots.</description>
    </item>

    <item>
      <title>Multi-tenant authorization shipped — and the four bugs adversarial QA caught before v0.17.0</title>
      <link>https://loomcycle.dev/blog/multi-tenant-authorization-and-the-four-bugs-qa-caught.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/multi-tenant-authorization-and-the-four-bugs-qa-caught.html</guid>
      <pubDate>Tue, 02 Jun 2026 18:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>v0.17.0 ships RFC L — OSS multi-tenant authorization and isolation. The seventh substrate primitive (OperatorTokenDef) joins AgentDef / SkillDef / MCPServerDef / ScheduleDef / WebhookDef / MemoryBackendDef with the same 5-op CRUD shape (create / rotate / retire / get / list), per-name advisory-locked versioning, file-based audit log, and lct_… token plaintext shown to the operator exactly once at creation. The auth middleware resolves the inbound bearer to an authoritative auth.Principal{TenantID, Subject, Scopes, TokenDefID, TokenSuffix} from the operator_token_def_active lookup and stamps it into ctx; the runs table gets a denormalized tenant_id column (migration 0036) so tenant-scoped list reads don't need a JOIN; the four run-creation sites (RunOnce / handleRuns / handleMessages / runSubAgent) thread the principal's tenant into the identity. Wire-side tenant_id is now ignored when it disagrees with the token's tenant — logged as kind=tenant_id_overridden for operator triage. A per-replica tokenCache with 30-second TTL plus Postgres LISTEN/NOTIFY invalidation on the loomcycle.operator_token_changed channel makes the hot path cheap; loomcycle operator-token create --copy-from-env is the zero-disruption migration from LOOMCYCLE_AUTH_TOKEN. principalTenantScope (list reads) + tenantVisible (single-row reads) wire the tenant boundary across the read endpoints; GET /v1/_me exposes the principal to the UI; within-tenant is whole-tenant (subjects collaborate); cross-tenant returns opaque 404 with no enumeration oracle. The Web UI ships a login page, role-aware nav (tenants see only runs; super-admins see operator-global tabs), and a super-admin tenant-focus switcher in the topbar. The feature shipped across three PRs (#323 substrate + #324 identity threading + #325 cache). Then the adversarial-QA pass found four authorization gaps the feature PRs missed. One CRITICAL (PR #327): the gRPC interceptor only authenticated — substrateGRPCCtx then stamped OperatorTokenDefPolicy{Admin:true} regardless of the caller's scopes, so any runs:read token could call OperatorTokenDef create over gRPC and mint substrate:admin tokens. Fix: requiredScopeForRPC with deny-by-default-to-admin posture, enforceScope() in both unary and stream interceptors. Three HIGH: (a) PR #328 — session continuation trusted session-id-as-secret; principal-A holding any runs:create token could POST to principal-B's session and the run executed under B's identity (cross-tenant memory, B's transcript replayed back to A over SSE, B's fairness cap spent, attribution as B). Fix: sessionOwnershipOK at all four session-identity sites; opaque-404 on mismatch. (b) PR #329 — execRetire had no last-admin guard; an operator who migrated off LOOMCYCLE_AUTH_TOKEN and retired their last admin token silently dropped into open mode (fail-OPEN). Fix: refuse retire when LegacyTokenSet=false AND the active-admin count would hit zero. (c) PR #330 — requiredScopeFor had typos that left mutating routes ungated: POST /v1/agents/{id}/cancel keyed on DELETE; /v1/runs/{id}/interrupts/* fell through to ""; the per-user channel surface had no map case (dead channel:* vocabulary); /metrics fell through to "". Fix: every route mapped; regression-grade tests for each case. One smaller fix worth naming (PR #333): memory:read / memory:write removed from the scope catalog — they were grantable but no route enforced them; a scope the runtime never checks is a false boundary. PR #331 closed two cache issues: don't cache outage negatives (transient DB blip was amplifying into a 30s sticky lockout), and bound the cache size to 16384 entries to cap negative-spray memory amplification. The strategic shift: v1.0 reframes from "RFC L is the v1.0 capstone" to a pure hardening + distribution milestone (Homebrew + Docker + Claude Code plugin + one more adversarial-QA pass on the rest of the surface). Authentication shipped clean across three PRs; authorization needed a second pass.</description>
    </item>

    <item>
      <title>n8n Cloud's scanner — and why @loomcycle/n8n-nodes-loomcycle now ships in two editions</title>
      <link>https://loomcycle.dev/blog/n8n-cloud-scanner-and-the-slim-vs-full-edition.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/n8n-cloud-scanner-and-the-slim-vs-full-edition.html</guid>
      <pubDate>Mon, 01 Jun 2026 18:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>v3.0.0 of @loomcycle/n8n-nodes-loomcycle splits the package into two parallel editions from one repo. The default — Slim, on the main branch — is 14 nodes, zero runtime dependencies, passes n8n Cloud's @n8n/scan-community-package scanner. The full edition — on the full-edition branch as @loomcycle/n8n-nodes-loomcycle-full — is 18 nodes, includes the four AI-Agent Tool cluster sub-nodes (Memory Tool, Channel Tool, Sub-Agent Tool, MCP Server Tool) plus SSE-push triggers plus the Run "Wait for Completion" op, self-hosted only. The forcing function was n8n Cloud's scanner, which bans four categories of primitives: @langchain/core (every transitive dep), all timer primitives (setTimeout, setInterval, node:timers, globalThis, process), console, and non-(n8n-workflow) peer dependencies. Each ban corresponds to an n8n Cloud runtime invariant (memory safety, predictable scheduling, no leaked stdout, supply-chain hygiene). The cluster sub-nodes were built on langchain's tool-supply API; @n8n/ai-node-sdk has no tool path yet; there was no fix without removing them. The Run Wait-for-Completion op polled with setTimeout — removed; equivalent behavior via the Run Completed trigger or n8n's Wait node. The SSE-push half of both triggers became poll() — n8n schedules the tick, the node returns when it has events; detection latency is now the poll interval, dedup helpers (seen-set, cursor persistence in workflow static data) carried over. The engineering win the constraint forced: the LoomCycle Chat Model migrated off @langchain/core (BaseChatModel) to @n8n/ai-node-sdk (generate/stream over the SDK's Message types with supplyModel). That migration deleted three workarounds the prior post documented in detail — the BindTools override for tool-call ID round-trip, the RunnableBinding dance for prototype-chain reachability, and the synthetic tool-call-id minting at every wire boundary. Roughly 200 lines of compensation code, replaced by ~50 lines of straight-line translation. License switched Apache-2.0 → MIT (n8n-node lint flags non-MIT as community-package-json-license-not-default; MIT is the community-node convention; loomcycle core and @loomcycle/client stay Apache-2.0). Peer deps reduced to exactly { "n8n-workflow": "*" }; @loomcycle/client bundled at build time so the published tarball is zero runtime dep. CI now runs three jobs on every push: conformance lint (@n8n/node-cli lint pinned to 0.32.1), Cloud scanner replica (@n8n/scan-community-package, zero violations or build fails), and the regular 195-test suite + build. One transitive-dep wrinkle surfaced — @n8n/ai-node-sdk pulls @langchain/community, which requires ignore@^7.0.5, conflicting with eslint's ignore@5.3.2; npm install resolves loosely but npm ci rejects, and the scanner forbids an overrides fix. Fix: CI uses npm install --no-audit --no-fund instead of npm ci. Lockfile isn't published (files: ["dist"] is the entire tarball contents), so this is purely a CI-side concern. The slim package is the answer for n8n Cloud operators (and the first version that's installable on Cloud at all); the full edition is the answer for self-hosted operators who want the cluster sub-nodes (especially the MCP Server Tool dynamic-provisioning pattern). Submission to n8n's Creator Portal for Cloud verification is queued.</description>
    </item>

    <item>
      <title>Code as agent — and the design we replaced before shipping</title>
      <link>https://loomcycle.dev/blog/code-as-agent-the-design-we-replaced-before-shipping.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/code-as-agent-the-design-we-replaced-before-shipping.html</guid>
      <pubDate>Sun, 31 May 2026 22:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>RFC J shipped in v0.16. provider: code-js runs operator-authored JavaScript via goja as a first-class agent — same loop, OTEL spans, scheduler/webhook/A2A reachability, sub-agent composition, at zero token cost. The engineering core: we built two designs. PR #306's parked-goroutine continuation model worked but held a continuation across Provider.Call invocations (every other provider is stateless), depended on goja issue #97's parked-host-func quirk for cancel semantics, and couldn't resume across restart or replica. PR #307 superseded it with the stateless replay model: each Provider.Call builds a fresh goja runtime, fast-forwards through the run's transcript (which IS the durable memoization log), stops at the first un-recorded tool call via rt.Interrupt — which the loop then dispatches. The transcript already exists; no parallel state machine; resumable across restart and replica for free; each tool dispatches exactly once. Ambient determinism is always on so replay is divergence-free by construction: Math.random() seeded per-run, Date.now() anchored to the run's real start + monotonic per-call offset, no filesystem/setTimeout/fetch. LOOMCYCLE_CODE_AGENTS_DETERMINISTIC=1 sharpens to "freeze anchor+seed across runs" for cross-run reproducibility. A name mismatch against the recorded transcript sequence fails loud (code_agent_replay_divergence). The provider streams EventToolCall + StopReason "tool_use" exactly like an LLM driver; the loop dispatches (its ctx, hooks, OTEL, credentials) and re-invokes Call with the result. The provider never imports internal/tools — leaf-package layering preserved. Allowed-tools is the only sandbox layer beyond goja: a tool not in allowed_tools has no JS binding at all (ReferenceError, not permission-denied). Trade-offs accepted: O(N²) re-execution cost (mitigated by putting work in tools, not preludes); synchronous-blocking JS surface (parallel via Agent.spawn); CamelCase JS bindings (Memory.get / Channel.publish / Agent.spawn / WebFetch / mcp__server__tool) consistent across substrate, yaml, and JS — one rule, no casing translation. The architectural insight: the parked-goroutine design wasn't wrong about how to suspend JS; it was wrong about where state lives. The transcript already had everything we needed. Replay removed the parallel state machine and suspend/resume became symmetric for free.</description>
    </item>

    <item>
      <title>Two memory interfaces — flat KV and the layered paradigm honest about its shape</title>
      <link>https://loomcycle.dev/blog/two-memory-interfaces-flat-and-layered.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/two-memory-interfaces-flat-and-layered.html</guid>
      <pubDate>Sun, 31 May 2026 18:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>v0.15 shipped RFC I — a flat memory Backend interface with native hybrid ranker (semantic + recency + source + frequency), search-time dedup, and Mem9 as the first external implementation behind the new MemoryBackendDef substrate primitive. v0.16 ships RFC K's MemoryLayer alongside it — a separate optional capability for LLM-extract memory products like mem0, Zep, and Mem9 smart-mode. The forcing function was the product survey: every mature memory product turned out to be paradigm-mismatched against the flat-KV contract. Mem0 (57k stars, daily commits, funded), Zep/Graphiti (self-host deprecated → cloud lock-in disqualifies), Letta (agent framework not a memory store), OpenAI (no memory API; Vector Stores hard-pins the embedder), and Mem9 itself (zero tagged releases, alpha API, docs that refute the stub-tested wire shape point by point). The fix wasn't picking a better product — every LLM-extract product hits the same trap when forced into flat KV: caller-keyed Get/Set vs server-assigned UUIDs, faithful round-trip vs LLM may rewrite/merge/delete inputs, synchronous Set vs 202 PENDING async ingest, per-(provider,model) Stats vs server-owned embedding with no per-model telemetry. Adapting one means using ~20% of the product (its vector search) while fighting the other 80%. Two interfaces is the cure for one wrong shape: flat Backend stays frozen as the canonical contract; MemoryLayer (Add/Recall) becomes an optional capability with a Capabilities probe and a typed ErrCapabilityUnsupported refusal — fail closed, never silently no-op. The Memory tool's existing six KV ops stay; new memory.add and memory.recall ops route to MemoryLayer. The in-process sqlite-vec backend implements Backend only (the canonical KV+vector path). Mem9 is demoted to PREVIEW (LOOMCYCLE_MEM9_PREVIEW=1 gate) and re-targeted at MemoryLayer — its actual paradigm. Plus PR #302 closes the original cross-tenant leak in shared_key_with_prefix tenancy strategy and PR #304 fixes a webhook trust-boundary subtlety from QA: a signature-valid replayed delivery now returns 200 deduped + the original run_id (matching GitHub/Stripe expectations), not 401, because the 401 misled legitimate senders into rotating their secret on an idempotency issue. New verdict=accepted_replay distinguishes deduped re-sends from fresh accepts.</description>
    </item>

    <item>
      <title>Input webhooks — the signed-by-default front door for external events</title>
      <link>https://loomcycle.dev/blog/input-webhooks-the-signed-front-door.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/input-webhooks-the-signed-front-door.html</guid>
      <pubDate>Sat, 30 May 2026 22:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>RFC H (Input Webhooks) shipped across seven slices in v0.14.0/v0.14.1, adding WebhookDef as the fifth substrate primitive after AgentDef / SkillDef / MCPServerDef / ScheduleDef. External systems sign and POST events to /v1/_webhooks/{name}; loomcycle either spawns an agent run (delivery: spawn) or publishes to a channel to wake an agent already parked on subscribe (delivery: channel). Trust boundary follows verify-before-parse: signature verification over the raw bytes happens before any parsing, with HMAC-SHA256 envelope auto-detection covering Stripe (t=,v1= over t.body, ±5min window), GitHub (sha256=hex over body), bare-hex (Linear and many custom sources), plus a bearer fallback. Two-layer idempotency — in-memory dedup cache keyed by delivery-id (Layer 1) plus durable runs.idempotency_key with a UNIQUE partial index (Layer 2) — so re-delivered events land on the same run rather than spawning twice. Strict JSONPath payload projection (no wildcards, filters, recursion) projects the body into the RunInput; per-event credentials overlay the operator-env-allowlist-resolved credentials through the same RFC F seam scheduled runs use. Never-silently-degrade error contract: opaque 404 for unknown webhooks (no enumeration oracle), 401 for any auth/replay failure (no body detail — no oracle), 503 secret_unresolvable naming the env var (never its value), 400 for malformed bodies, 429 + Retry-After for rate limit. The engineering core of the release is two trust-boundary bugs the whole-feature code review caught and fixed with regression tests: (1) the dedup cache recorded delivery_id at the guard step, so a signature-valid request that was then rate-limited or mapping-rejected burned its id and dropped the sender's legitimate retry as a replay; split into seen()-check and record()-on-accepted so non-accepted deliveries stay retryable. (2) the mapped goal field from the external payload (a PR title, an issue body — attacker-influenceable) was marked trusted-text, bypassing the loop's untrusted fence; changed to untrusted-block{kind:webhook_payload} so the model treats webhook-payload content as the attacker-influenceable input it actually is. Provider recipes shipped in the help topic for GitHub (sha256= envelope), Stripe (t=,v1= envelope), Linear (bare-hex), GitLab (X-Gitlab-Token shared secret), generic Authorization-bearer (n8n/Zapier/internal), and channel-mode CI callbacks. Two admin-bearer-gated triage endpoints help debug a silently-failing webhook: GET /v1/_webhooks/{name}/recent-deliveries shows the last 50 invocations with delivery_id, verdict, received_at, run_id; POST /v1/_webhooks/{name}/test is a dry-run validator that returns would_accept, verdict, and a run_input_preview (credential key names only, never values) with no run created and no dedup record written. Honest residual scope: base64-encoded HMAC digests (Shopify's X-Shopify-Hmac-Sha256) and custom signed-payload constructions like Slack's v0:ts:body are not yet supported (bearer fallback covers many of those); cluster-wide dedup and rate-limit are deferred (Layer 2 durable idempotency is the cross-replica backstop today).</description>
    </item>

    <item>
      <title>Loomcycle speaks A2A — server, client, and the INPUT_REQUIRED bridge that wasn't supposed to ship</title>
      <link>https://loomcycle.dev/blog/loomcycle-speaks-a2a.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/loomcycle-speaks-a2a.html</guid>
      <pubDate>Sat, 30 May 2026 18:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>RFC G (A2A protocol integration) shipped across eight slices on a feature branch this week, built against the official a2a-go v2.3.1 SDK as a direct dependency. The server surface (off by default, gated by LOOMCYCLE_A2A_ENABLED=1) mounts additively — none of /v1/*, MCP, or /ui changes — and publishes an AgentCard at the well-known URI with three protocol bindings advertised: REST at /a2a/v1, JSON-RPC 2.0 at /a2a/jsonrpc, and gRPC riding loomcycle's existing gRPC server (HTTP/2 needs no path mount). Each exposed loomcycle agent becomes one AgentCard skill; inbound messages carry a skillId in metadata that routes to the mapped agent; a request naming an unknown or unexposed skill is rejected as FAILED so peers cannot reach agents that aren't explicitly exposed. Two new substrate primitives carry the configuration content-addressed and versioned like every other Def: A2AServerCardDef (declares exposed agents, security schemes, optional signing-key env var name) and A2AAgentDef (registers a remote peer by well-known card URL or direct endpoint+binding; synthesizes one a2a__&lt;peer&gt;__&lt;skill&gt; tool per expected-skill pair at boot, mirroring how mcp__&lt;server&gt;__&lt;tool&gt; tools register). Signed cards use ES256 over RFC 8785 JSON Canonicalization Scheme with the public key embedded as a self-contained JWS; signing is best-effort and never fails card serving — a missing/unallowlisted env var serves the card unsigned with one trace line. Inbound verification is tolerant by default; verify_signed_card: true proves card integrity (not altered after signing) but not peer identity (TLS provides identity via the well-known URI fetch). Multi-tenant routing supports three modes (none/host/path) with the routed tenant authoritative — a cross-tenant request cannot borrow another tenant's identity from the message body. The headline engineering reversal: the locked RFC's Decision 9 explicitly deferred INPUT_REQUIRED/AUTH_REQUIRED interactive task states to v2 — during implementation we realized loomcycle's existing Interruption tool (shipped v0.8.16 for human-in-the-loop) already was the substrate A2A needed, so slice A2A-6 shipped the INPUT_REQUIRED↔Interruption bridge instead. A run that parks on Interruption.ask surfaces TASK_STATE_INPUT_REQUIRED; a follow-up message on the same taskId resolves the interruption via the same notification bus the Interruption tool already waits on, and the same run resumes to its real terminal state. AUTH_REQUIRED stays deferred (peer credentials come up front through RFC F's per-run channel, not negotiated interactively). The whole-feature code review caught a real parked-run lifetime defect: the SDK creates a per-streaming-request context with deferred cancel, and our first cut drove RunOnce with that ctx — so the instant our iterator parked, the SDK's deferred cancel tore the run down through the Interruption tool's Bus.Wait, killing the parked run with context.Canceled before any resume could arrive. Unit tests with uncancelled context.Background() missed it; fix is context.WithoutCancel + executor-owned cancel, with a regression test pinning the invariant. End-to-end integration test against the real a2aclient caught two more bugs: REST binding mounted without prefix-strip (SDK relative routes 404'd), and rejected-message event ordering (SDK requires first event to be Task/Message, not a bare status update). Residual scope named honestly: oauth2/mtls peer auth accepted in config but only http/apiKey wired today; push notifications deferred per Decision 11 coordinating with RFC H's outbound-event mechanism; JCS canonicalization not fully RFC 8785-faithful for fractional/large numbers (latent — AgentCards carry only integers). The strategic upside: any Microsoft Agent Framework, Google ADK, or LangGraph deployment with native A2A support can now call a loomcycle agent without writing a per-framework adapter, and loomcycle agents can compose remote A2A peers as ordinary tools.</description>
    </item>

    <item>
      <title>From Go-bundled to JSON-pluggable — and into Claude Code itself</title>
      <link>https://loomcycle.dev/blog/mcp-tools-as-data-then-claude-code-plugin.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/mcp-tools-as-data-then-claude-code-plugin.html</guid>
      <pubDate>Fri, 29 May 2026 18:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>An architectural reconsideration this week moved loomcycle's curated MCP catalog from Go-bundled (recompile required for every new recipe) to JSON-pluggable (drop a file in $LOOMCYCLE_MCP_RECIPES_ROOT, run `loomcycle mcp-registry list`, done). PR #274 ships 13 bundled recipes via //go:embed plus filesystem overlay with complete-replace semantics and seven CLI verbs (list / show / append-to-config / add / remove / enable / disable). The shape choice was deliberate: each recipe is Claude Code's .mcp.json per-server JSON shape with a sibling _loomcycle metadata block (description / transport / pool_size / env_vars_required / credentials / schedule_compatible / agent_prompt_hint), so top-level fields are byte-compatible with Claude Code's format — a loomcycle recipe drops cleanly into .claude/mcp.json by stripping the metadata, and the reverse lifts cleanly. That format-share unlocked PR #275: `loomcycle import claude-code` walks a Claude Code repo's .claude/ tree (agents, skills, mcp.json + project-root .mcp.json), maps each artifact to loomcycle yaml, recipe-matches MCP servers by package (preserving operator's custom names while substituting transport-correct command/args/env from the curated library), and emits a typed ImportReport with what mapped, what skipped, what was unmapped. Heuristics fire only on conservative name patterns (*-scheduler agents get a schedule_def_scopes: ["any"] stub with a "tighten manually" comment; *-evolver agents get agent_def_scopes: ["self"]); everyone else gets nothing. Today's release closes the loop: claude-code-plugin-loomcycle (denn-gubsky/claude-code-plugin-loomcycle, distributed via Claude Code's plugin marketplace — not npm, no build step) pre-wires loomcycle as an MCP server with four userConfig prompts at install (bin_path, config_path, sensitive auth_token, base_url), ships six slash commands (/loomcycle:connect, run, runs, cancel, snapshot, eval), four bundled skills (spawn-evaluator, replay-failed-run, diff-agentdefs, import-claude-code), and two opt-in hooks (capture-run-telemetry, auto-snapshot-on-error — disabled by default, matching loomcycle's default-deny posture). Notably zero loomcycle-side code changes — the plugin consumes the existing `loomcycle mcp` stdio server plus 20+ meta-tools verbatim. From now on the end-to-end story is: author in Claude Code (IDE-as-truth posture untouched), import into loomcycle (one CLI command, CI-friendly with --json + --emit-recipes), drive runtime from inside Claude Code (slash commands, same auth token, OTEL trace context propagated end-to-end). The lesson is the architectural choice that comes earlier than the artifact: moving the catalog from Go-bundled to JSON-pluggable was a decision about who owns the catalog (operator) and what shape that ownership takes (a file format both ends already speak). Once that was settled, the import command and the plugin became obvious next moves rather than ambitious ones.</description>
    </item>

    <item>
      <title>Seven frameworks and the row that's missing</title>
      <link>https://loomcycle.dev/blog/seven-frameworks-and-the-row-thats-missing.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/seven-frameworks-and-the-row-thats-missing.html</guid>
      <pubDate>Fri, 29 May 2026 09:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>The 2026 agent-framework surveys (DSPy, Claude Agent SDK, OpenAI Agents SDK, CrewAI, Microsoft Agent Framework, LangGraph, Google ADK) rank seven contenders along durable execution, observability, multi-tenancy, sandboxing, and provider lock-in. The taxonomy is useful, but it shares an unnamed structural assumption: the agent runtime lives either inside your application's process or inside the vendor's cloud. Loomcycle is the third shape — a single Go binary in a sidecar, on your infrastructure, owning the loop. Once the runtime moves out of the application, observability becomes a deployment concern (loomcycle ships three OTEL profiles), durable execution survives application restarts as a default (Pause/Resume/Snapshot + multi-replica HA shipped), per-tenant fairness is a runtime invariant (cluster-wide on the admitting semaphore), the substrate becomes versionable (content-addressed AgentDef/SkillDef/MCPServerDef/ScheduleDef), scheduled autonomous runs become first-class (RFC E), per-user credentials cross the trust boundary without touching the agent (RFC F), MCP works both ways (loomcycle is both client and server on the same auth surface), and your application doesn't pick up the runtime's language (loomcycle is one Go binary you talk to over HTTP/gRPC/MCP/TS adapter/OpenAI-compat). The post is honest about when loomcycle is the wrong answer (DSPy for prompt optimization, Claude Agent SDK for Claude Code-as-library, Microsoft for .NET+OWASP, the hosted runtimes for managed durability, CrewAI for the very first hour of prototyping). The eighth row of the framework matrix, written out plainly.</description>
    </item>

    <item>
      <title>Scheduled runs at 30,000 fires — and the double-fire we caught at the ceiling</title>
      <link>https://loomcycle.dev/blog/scheduled-runs-and-the-double-fire-ceiling.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/scheduled-runs-and-the-double-fire-ceiling.html</guid>
      <pubDate>Thu, 28 May 2026 23:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>v0.12.7 ships RFC E — ScheduleDef as the fourth substrate primitive after AgentDef / SkillDef / MCPServerDef — alongside its sibling RFC F (per-run credentials). Operators declare cron in yaml (two entry styles: per-user-tier templates that orchestrators fork, or operator-owned standalone schedules); the sweeper fires real RunInputs on each tick; on_complete hooks deliver the result via channel.publish, memory.set, or mcp.call (closed three-kind set, no plugin surface). Configuration is opt-in: LOOMCYCLE_SCHEDULER_ENABLED=true, LOOMCYCLE_SCHEDULER_TICK_SECONDS (default 30), LOOMCYCLE_SCHEDULER_FIRE_TIMEOUT_SECONDS (default 600), and a LOOMCYCLE_SCHEDULER_ENV_ALLOWLIST gate for user_credentials_from_env. The compound stress test (PR #271) seeds 310 schedules across three phases and asserts each MCP server sees exactly N calls with zero bearer mismatches — then scaled to 100,000 surfaced a real double-fire race at x30,000: every schedule fired twice because RecordResult took longer than the tick interval and the sweeper had no in-flight guard. Closed by a sync.Map tracker (PR #272); curve now linear through x50,000 at ~1000 MCP calls/sec on an M1 laptop. Zero credential mismatches across 200,000+ MCP calls in the post-fix sweep — the per-fork user_credentials map flowed cleanly through every tick. Single-replica only for v0.12.7; cluster-mode advisory locks on the v0.12+ roadmap.</description>
    </item>

    <item>
      <title>Reliable under stress, sustainable for hours: seven load experiments in two days</title>
      <link>https://loomcycle.dev/blog/reliable-under-stress-sustainable-for-hours.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/reliable-under-stress-sustainable-for-hours.html</guid>
      <pubDate>Thu, 28 May 2026 22:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Yesterday's writeup ended on a promissory note (cluster, Linux, sustained, find the real saturation knee). Two days and seven experiments later — all on a single 32-thread Linux Xeon E5-2697A v4 — this post is the synthesis: single-binary baseline (x10000 burst, 100% completion, p99=4.0s, peak active ~1500). Cluster burst sweep r in {1,2,3,4} x x5000 showing a sharp phase transition at r=2 to r=3 where p99 collapses 15x (54s to 3.6s) as the load splits below the per-replica saturation knee. Cancel + crash injection: 127/127 cancels acked across replicas via Postgres LISTEN/NOTIFY at p50=21ms, p99=130ms (38x under default timeout); a docker-killed replica reaped at T+20s with stop_reason='replica_died' in a single batched UPDATE, zero zombies, zero quota leak. 15-min sustained at r=4 (56,000 circuits, 168,000 runs, 100%, 0.7% load spread, no drift). 30-min sustained with the full Profile A OTEL stack alongside (109,000 circuits, 327,000 runs, ~180 runs/sec sustained, OTEL adds ~8% p50 at sampler=1.0). A capacity probe x3000-x5000 showing wave wall perfectly linear at 16s per +1000 circuits with p99 flat 3.6-4.2s. A saturation probe x6000-x10000 that finally names the keep-up boundary: the cluster saturates between x6000 and x8000, absorbs a consistent ~6,650 circuits/wave above the knee, and degrades softly (silent regressions + launch-collapse bimodal pattern) rather than catastrophically. Net: reliable under stress, sustainable over at least 30 minutes within capacity, with a soft ceiling and tractable failure modes. With charts for cluster latency percentiles, sustained wave wall, capacity wave wall, saturation wave wall, and a live Grafana snapshot from the 30-min OTEL run.</description>
    </item>

    <item>
      <title>Three MCP tokens in one run — and the agent never sees a single one</title>
      <link>https://loomcycle.dev/blog/three-mcp-tokens-one-run.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/three-mcp-tokens-one-run.html</guid>
      <pubDate>Thu, 28 May 2026 18:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>JobEmber's nightly autonomous job-search agent needs three per-user bearer tokens in one run — a private jobs API, the user's Slack, the user's Telegram. The v0.8.14 substrate carried exactly one. Handing all three to the agent would leak them into transcripts, OTEL spans, sub-agent contexts, and any prompt-injectable tool result. RFC F (PR #262) extends the wire shape with a user_credentials map across all four transports (HTTP, gRPC, MCP spawn_run, TypeScript adapter); a new ${run.credentials.&lt;name&gt;} substitution expands the map at the HTTP boundary inside Client.do(), non-mutating and per-request. The agent has no introspection surface (no Context.self, no Credentials.list tool); the values never persist to transcripts, snapshots, or OTEL spans; sub-agents inherit through ctx the same way they inherited v0.8.14's single bearer; missing credentials drop the header and emit a tracing event for triage. Back-compat sugar promotes the legacy user_bearer field into user_credentials["default"] when set, so the legacy ${run.user_bearer} template still resolves and migration is uncoordinated. The companion RFC E (ScheduleDef) stores the same map shape for scheduled runs — the canonical credential surface across the substrate; data-layer slice landed alongside F today, runtime + Web UI follow.</description>
    </item>

    <item>
      <title>15,000 agents on a synthetic provider — finding loomcycle's real ceiling</title>
      <link>https://loomcycle.dev/blog/15000-agents-on-a-synthetic-provider.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/15000-agents-on-a-synthetic-provider.html</guid>
      <pubDate>Wed, 27 May 2026 23:55:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>The 3000-agent run starved on provider quota before it could characterise loomcycle's own limits, so we built a synthetic LLM provider — same providers.Provider wire shape, zero HTTP, zero quota, deterministic 429/500/latency injection — and pushed the substrate to 5,000 circuits: 15,000 agent runs held live in memory at once across 10,000 channels and 10,000 memory entries, 351,064 stored events, completed 5,000/5,000 under a 15% injected error rate. Three real bugs found and shipped: a launch-storm silent-write regression (pool cap + narrow connection-retry), a cooldown cascade that collapsed a no-fallback run to 185/1000 over 22 minutes (same-provider retry budget before MarkRateLimited; recovers to 1000/1000 in 42s), and a heartbeat-budget misfire at the launch crest. Then we lifted the harness launch gate 10x (50 to 500 concurrent circuits) and re-ran on more CPU: peak active runs jumped 141 to 1,500, heap 520MB to 4.1GB, p50 latency 1.9s to 36s — yet completion stayed 5000/5000, the admission queue never left zero, and substrate errors stayed at zero. That finally named the real bottleneck: pgxpool size and per-op connection queueing under 1,000+ concurrent runs, with three fix paths (bump pg_max_open_conns, shard agent-to-connection assignment, per-replica hot-path caching). Not the agent loop, channel bus, memory, goroutines, or heap — the connection pool. With completion, provider-routing, latency, and goroutine charts.</description>
    </item>

    <item>
      <title>Route agents by data sensitivity: local where it matters, cloud where it doesn't</title>
      <link>https://loomcycle.dev/blog/route-agents-by-data-sensitivity.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/route-agents-by-data-sensitivity.html</guid>
      <pubDate>Wed, 27 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>The honest answer to "can we use agents on sensitive data?" isn't everything-local (quality collapse) or everything-cloud (residency violation) — it's routing by sensitivity. Pin sensitive-data agents to a local model (ollama-local) so their data physically never leaves your infrastructure; route everything else to the best cloud model for the job. Provider is a per-agent policy, not a global default — shipped in loomcycle today. Residency you can prove with a packet capture beats a retention promise you have to trust. With real ollama-local vs anthropic agent-config examples and the honest local-model quality tradeoff.</description>
    </item>

    <item>
      <title>3000 agents + 2000 memories + 2000 channels in one stress test</title>
      <link>https://loomcycle.dev/blog/3000-agents-in-one-stress-test.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/3000-agents-in-one-stress-test.html</guid>
      <pubDate>Tue, 26 May 2026 23:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>100 users × 10 circuits — 7000 entities (3000 agent runs + 2000 memory entries + 2000 channels) tracked cleanly by loomcycle's v0.12.x substrate. The agents themselves starved on provider capacity, because Anthropic and Ollama both cap parallel calls at roughly the number we needed at peak. Multi-provider fallback didn't fix it (correlated ceilings; structural lesson). Five real substrate bugs found and shipped the same day. The bottleneck moved from "loomcycle internals" to "upstream provider concurrency limits" — the result an agentic runtime is supposed to produce. The first of the load-testing exercises promised in the v1.0 hardening pass; cluster-mode load test queued up next. Includes the recorded x1000 session.</description>
    </item>

    <item>
      <title>Multi-replica HA — the seven phases that get loomcycle close to v1.0</title>
      <link>https://loomcycle.dev/blog/multi-replica-ha-the-seven-phases.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/multi-replica-ha-the-seven-phases.html</guid>
      <pubDate>Tue, 26 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Seven phases over four weeks took loomcycle from a single-process binary to a multi-replica cluster. Postgres LISTEN/NOTIFY backplane, cluster-wide per-user fairness, cross-replica cancel and pause/resume, advisory-lock singleton sweepers, DB-backed session locks and hooks. Two-replica docker-compose demo ships in the repo. Not v1.0 yet — load testing, functionality testing across the cluster matrix, and a hardening pass against cluster invariants are still ahead — but the biggest step toward it.</description>
    </item>

    <item>
      <title>What it took to make loomcycle a first-class n8n citizen</title>
      <link>https://loomcycle.dev/blog/loomcycle-meets-n8n.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/loomcycle-meets-n8n.html</guid>
      <pubDate>Mon, 25 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>@loomcycle/n8n-nodes-loomcycle went from v1.0.0 to v1.2.0 in four days. Five cluster sub-nodes including a LangChain Chat Model wired to loomcycle's gateway, two trigger nodes, six example workflows. The Tools Agent integration saga: bindTools, RunnableBinding, _getType detection, and a defence-in-depth synthetic tool_call_id at every wire boundary that took three patches to chase down.</description>
    </item>

    <item>
      <title>Becoming OpenAI-shaped without becoming OpenAI</title>
      <link>https://loomcycle.dev/blog/openai-shaped-without-becoming-openai.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/openai-shaped-without-becoming-openai.html</guid>
      <pubDate>Sun, 24 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Loomcycle now serves three OpenAI-shaped endpoints: POST /v1/_llm/chat (loomcycle-native gateway, v0.11.0), POST /v1/chat/completions (Chat Completions shim, v0.11.3), and POST /v1/embeddings (Embeddings shim, v0.11.4). Every n8n Chat Model, LangChain consumer, RAG pipeline, and vector DB that defaults to OpenAI now works against your loomcycle by changing only the base URL and auth token. The interesting part isn't the wire format — it's what the shim deliberately omits, and what loomcycle adds that OpenAI doesn't.</description>
    </item>

    <item>
      <title>Scrubbing the model's incoming mail: a PostTool hook for WebFetch, WebSearch, and Brave</title>
      <link>https://loomcycle.dev/blog/scrub-before-the-model-sees-it.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/scrub-before-the-model-sees-it.html</guid>
      <pubDate>Sat, 23 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>A content-level prompt-injection defence built outside the model. WebFetch, WebSearch, and Brave Search results pass through a PostTool hook that scrubs sixteen injection patterns plus Cyrillic-homoglyph variants before reaching the agent's context. LIFO ordering with url-discovery, fail_mode: closed, and the JSON-nesting bypass that landed in production and got closed two hours later in review.</description>
    </item>

    <item>
      <title>When the agent is in one container and its definition is in another</title>
      <link>https://loomcycle.dev/blog/agents-across-the-container-wall.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/agents-across-the-container-wall.html</guid>
      <pubDate>Fri, 22 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>The historical loomcycle pattern read .claude/agents/*.md and .claude/skills/*/SKILL.md off a shared filesystem checkout. That breaks the moment the runtime and the app run in independent containers. We solved it with a substrate trio — AgentDef, SkillDef, MCPServerDef — each content-addressed by SHA-256 over a fixed field set, pushed at boot from the consumer's Docker image, resolved through one canonical lookup. With the cleanup-PR story of why the hash had to move from consumer-side to server-side.</description>
    </item>

    <item>
      <title>Even with no-training contracts, the LLM should never see your name</title>
      <link>https://loomcycle.dev/blog/the-llm-shouldnt-see-your-name.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/the-llm-shouldnt-see-your-name.html</guid>
      <pubDate>Tue, 19 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Anthropic's no-training tier is a contractual promise about retention; it is not a reduction of what gets sent. We rebuilt the JobEmber data path so identifying PII never reaches the LLM at all — placeholders for names, emails, phones, and addresses; server-side comparison for location preferences via a narrow MCP tool — and dropped Read and HTTP from every agent that didn't strictly need them.</description>
    </item>

    <item>
      <title>What tools should an agent reading attacker HTML get? None.</title>
      <link>https://loomcycle.dev/blog/zero-tool-zero-secret-agent.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/zero-tool-zero-secret-agent.html</guid>
      <pubDate>Mon, 18 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>The job-posting-parser agent runs against attacker-controllable third-party HTML body text. We built it as the reference for the strictest profile we know how to ship: zero tools, zero secrets, tag-wrapped inputs, Zod-strict output. Each invariant covers a different failure mode; none is sufficient alone. Companion piece to the PII redaction post, frames a future deep-dive on content-level prompt-injection defence.</description>
    </item>

    <item>
      <title>Who decides which URLs an agent can visit? It's not the runtime.</title>
      <link>https://loomcycle.dev/blog/who-decides-which-urls-an-agent-can-visit.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/who-decides-which-urls-an-agent-can-visit.html</guid>
      <pubDate>Sun, 17 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>A Sunday-afternoon production-deploy test caught a structural gap in loomcycle's URL allowlist: pre-enumerated allowlists don't fit dynamic discovery, and the runtime doesn't have the context to fix that. We extended Pre-hooks with per-call host widening (v0.8.17), moving the decision out of loomcycle into the operator service that has the context to make it.</description>
    </item>

    <item>
      <title>The final bench scoreboard — 25 models, $21.92, all CAPABLE</title>
      <link>https://loomcycle.dev/blog/the-final-bench-scoreboard.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/the-final-bench-scoreboard.html</guid>
      <pubDate>Fri, 15 May 2026 10:00:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>Sweep #6 with v3 cases + multi-judge consensus across three provider families. Every model hit CAPABLE; the real signal is cost-per-pass and overall-pass count. ollama/deepseek-v4-pro topped both quality (0.91 semantic) and price ($0.0022/pass) — beating opus at 1/75 the cost.</description>
    </item>

    <item>
      <title>How we selected agent- and tool-capable models with our own benchmark</title>
      <link>https://loomcycle.dev/blog/how-we-selected-agent-and-tool-capable-models-with-own-benchmark.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/how-we-selected-agent-and-tool-capable-models-with-own-benchmark.html</guid>
      <pubDate>Thu, 14 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>We benchmarked five providers and all current flagship models for agentic tool-calling. Four sweeps in, we found a bug in our own bench harness that invalidated most of our conclusions. Here's what we learned, what the corrected findings actually say, and what's going into v2 of the bench.</description>
    </item>

    <item>
      <title>Our MCP server authenticated everyone as me</title>
      <link>https://loomcycle.dev/blog/our-mcp-server-authenticated-everyone-as-me.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/our-mcp-server-authenticated-everyone-as-me.html</guid>
      <pubDate>Tue, 12 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>We added MCP to fix one auth leak (typed schemas, bearer tokens out of the model's view) and quietly created another: a shared developer bearer that authorized every user's agent calls as the developer. Documents linked to the wrong user. The bug took a stretch of days and a persistent second user to find. Here's the story and the per-run bearer mechanism (v0.8.14) that fixed it.</description>
    </item>

    <item>
      <title>How I burned $80 on Claude Code in a Sunday afternoon</title>
      <link>https://loomcycle.dev/blog/the-80-dollars-i-burned-on-claude-code.html</link>
      <guid isPermaLink="true">https://loomcycle.dev/blog/the-80-dollars-i-burned-on-claude-code.html</guid>
      <pubDate>Thu, 07 May 2026 22:30:00 GMT</pubDate>
      <author>denn@loomcycle.dev (Dennis Gubsky)</author>
      <description>100 parallel claude code --print instances. MacBook Pro M1 fan at maximum. ANTHROPIC_API_KEY inherited via execve. Opus 4.7 on a dumb classification task. The bill: $80. Anthropic's robot denied reimbursement. The architectural lesson became loomcycle.</description>
    </item>
  </channel>
</rss>
