Code agents without a host filesystem — JS bodies through the substrate.
Code-as-agent shipped in v0.16.0 (RFC J) as the seventh way to author a loomcycle agent: provider: code-js runs operator-authored JavaScript via goja as a first-class agent, with the same loop, OTEL spans, scheduler / webhook / A2A reachability, sub-agent composition, and allowed_tools sandbox the LLM-backed agents get. Zero token cost; stateless replay over the run's transcript; resumable across restart and replica for free.
That ship had one shape it never quite let go of: the JS body lived on the loomcycle sidecar's filesystem. agent_code/<name>/index.js next to loomcycle.yaml, with an operator overlay via $LOOMCYCLE_CODE_AGENTS_ROOT. Every other AgentDef attribute — system_prompt, allowed_tools, tier, model — had been content-addressed, versioned, and snapshot-portable through the substrate since v0.9. The JS body alone wasn't.
That was fine for the configuration RFC J was sized for: a local-dev binary, a single VPS, or a developer iterating on JS files on their own machine. It didn't survive three deployment shapes operators were already running:
- Cloud. A pure-cloud deploy has no host filesystem to bind into the loomcycle container. The closest thing is "build the JS into the image," which couples the agent's content to the container's release cycle — exactly the coupling AgentDef versioning was supposed to break.
- Container orchestration. Even in Kubernetes / Compose where bind-mounting a host path is possible, doing so makes the deployment less portable: now the image needs a specific volume mounted at a specific path, and "
docker pull loomcycle && run" no longer covers it. - n8n interactive. An n8n workflow author defines an agent at design time inside the n8n canvas. They never see the sidecar's filesystem. Asking them to
kubectl cpa JS file into a sidecar pod is not a real workflow.
v0.19.0 (PR #349 + follow-up #350) closes that by threading inline code_body through AgentDef as a hash-significant content field. v0.20.0 (PRs #351 + #352 + #353) lights up the Web UI for it and closes a sibling MCPServerDef asymmetry — discovery now runs at ingestion instead of as a separate manual step. Same engineering pattern as yesterday's static-vs-dynamic post: the substrate primitive had two ingestion paths, the dynamic one needed to uphold the same contract as the static one, and the last seam that didn't was the one nobody had been exercising.
The fix shape: code_body as content, not as a path
The core change in PR #349 is a single new field on the canonical content struct: AgentContent.CodeBody, omitempty, alphabetical tag order so an empty body serialises away. Two consequences fall out of that one design choice:
- Zero content_sha256 churn for non-code agents. An empty
code_bodyfield thatomitempty's out of the JSON canonicalization means every existing AgentDef hashes byte-for-byte as before. Operators running v0.18.x with hundreds of LLM-backed agents don't see a single forced re-version on upgrade. - Inline code is content. Two agents with byte-different bodies hash differently; verify and dedup stay correct. A new AgentDef version with new JS is genuinely new, even though the rest of the agent didn't change.
The field threads through what we've come to call the four mirrors:
config.AgentDef.Code(yamlcode:) — the operator-authored entry pointmergedDef— the merge layer between yaml and substrate overlayslookup.SubstrateAgentDef+ToConfigDef— the resolver layer the runtime consumesAgentContent— the canonical hash input
Plus providers.RunMeta.CodeBody rides from the loop to the provider, keeping the leaf providers package's no-store / no-tools invariant intact (the provider gets the body it needs without importing internal/store). All four run-creation paths — RunOnce, /v1/runs, the /v1/sessions messages-continuation, and runSubAgent — populate it, so sub-agents inherit the parent's body identically.
The provider prefers an inline body over the filesystem when both are present, and the compiler cache is re-keyed by content_sha256 instead of agent name. That second change is load-bearing: if the cache were name-keyed, a new AgentDef version with new JS would serve the stale program from the prior version's compile. Hash-keyed means a new body compiles fresh; an unchanged body across versions still hits cache.
The gate is the existing LOOMCYCLE_CODE_AGENTS_ENABLED switch (the same one that decides whether to register the code-js provider at all). create and fork refuse a non-empty code_body when the switch is off — fail loud at registration time rather than emit a hash-significant field the runtime will then ignore. A dedicated LOOMCYCLE_AGENT_DEF_MAX_CODE_BYTES cap (256 KB default) prevents a runaway upload from blowing up the def store; parse-and-compile validation lives in a new pure codejs.Validate function (single source of truth for the compile flags, no cycles between internal/codejs and internal/agentdef).
Out of scope on purpose: forking a filesystem-backed code agent into the substrate fails loud with a hint to supply an inline code_body. Auto-materialising the disk body at fork time would re-introduce the FS dependency we're removing — the operator would think they had a portable substrate row, only to discover their fork still needed the original sidecar's agent_code/ directory. Forking an inline parent inherits the body for free.
Three review-fixes worth naming in the same PR
PR #349 went through a self-review pass after the core implementation landed. Three findings, all of them in the "deceptively small but load-bearing" category.
#1 — Boot-fatal validation only knew about the filesystem
PR #349 · review fixThe headline no-FS-bind case was failing log.Fatalf at boot.
validateCodeAgents at boot iterates every provider: code-js agent and parse-compiles its body to catch syntax errors before any run happens. It validated only the filesystem: a static yaml agent shipped with an inline code: body and no host index.js failed the boot with log.Fatalf("code agent foo: index.js not found") — exactly the deployment shape v0.19 was supposed to unblock. Fix: validate via codejs.Validate when def.Code is set, falling back to the filesystem compile only when no body is declared. The check now mirrors the run-time provider's inline-over-FS precedence.
#2 — The FS path was re-reading disk on every replay turn
PR #349 · review fixThe compiler refactor dropped a by-name cache, turning every replay turn into a disk read.
Code-as-agent's stateless replay model means each Provider.Call builds a fresh goja runtime and fast-forwards through the run's transcript until the next un-recorded tool call. A code-agent run is therefore a sequence of Provider.Call invocations — one per tool dispatch — and each one previously hit a by-name cache for the compiled program. The compiler refactor for inline bodies kept the content-hash-keyed cache (correct for inline) but accidentally dropped the by-name early check on the FS path: every replay turn re-read agent_code/<name>/index.js and re-hashed.
Fix: restore a fsCache keyed by agent name for the filesystem path. The inline path stays content-hash-keyed (a code_body is versioned, so a by-name cache there would serve a stale program after a new version was promoted). Inline re-hashes per turn — cheap CPU, no I/O. The regression test deletes index.js after the first load and asserts the by-name cache still serves it — fail-before behaviour was a re-read that errored.
#3 — AgentDef had a false dedup contract
PR #349 · review fixAgentDef.execCreate had no content-addressed dedup. Every TS ensureCodeAgent reported changed: true.
MCPServerDef got idempotent-create-on-matching-content_sha256 in yesterday's PR #343 (the dedup that closed the version-spam where one MCP server had grown to 19 identical versions). AgentDef didn't — its execCreate minted a new version on every create call regardless of whether the content was byte-identical to the active row.
Which meant the new TS client convenience ensureCodeAgent({name, code, ...}) couldn't keep its "byte-identical re-register is a no-op" contract: create always succeeded, deduplicated was never returned, and changed was always true. The consumer-side if (changed) console.log(…) pattern would log on every boot.
Fix: add the same idempotent-create dedup MCPServerDef uses. Compare content hash against the active row at create time; return that row with deduplicated: true instead of minting a duplicate version. The TS changed flag now means what it says, and the dedup contract is uniform across both substrate primitives that consumers re-register on every boot.
The three-way hash drift PR #350 closed
PR #350 · review #4 / #5Once code_body became hash-significant, the .md-discovery path and the loomcycle hash agent CLI computed a different content_sha256 than the substrate did. Three producers, three definitions.
AgentDef has three places that produce a content_sha256:
- The substrate path —
signFromMergedDef, hashes the canonicalAgentContent. - The .md-discovery path —
FromYAMLAgent+agentFromDiscovered, used when an operator drops a markdown file with frontmatter into the agents directory. - The CLI —
loomcycle hash agent, used to compute a content hash from a yaml file offline (for CI, for verify-before-create).
All three are supposed to converge: an agent's content hash is a property of the agent's content, not of how that content reached the runtime. Before PR #350, only the substrate producer carried CodeBody: FromYAMLAgent didn't read it, agents.Agent (the .md frontmatter struct) didn't have a Code field, and the CLI's hand-copy of the content struct in cli/hash.go omitted it. So a code agent's hash diverged depending on how it was ingested — a .md-discovered agent's CLI-computed hash would never match the substrate's, and operator verify would silently disagree with operator-issued create.
Plus a sibling drop: mergeAgentDef (the merge layer that composes a .md-discovered agent with a yaml override on the same name) copied every mutable field except the new Code. So an inline code: in a yaml override layered onto a .md-discovered agent was silently dropped — the runtime fell back to a nonexistent index.js and failed.
Fix: add Code to agents.Agent + the frontmatter struct (yaml code:) + parseAgent; map it in FromYAMLAgent, agentFromDiscovered, and the cli/hash.go hand-copy. Add the override copy to mergeAgentDef. Regression tests assert the three producers hash a code-bearing agent identically.
The lesson worth taking forward. When a content-addressed hash exists, there is exactly one definition of "what is the content." Every producer must converge on that definition. Drift between producers turns the hash from a useful primitive into a footgun: verify says "matches" or "doesn't match," and the operator believes it. A drift means the answer doesn't mean what the operator thinks it means.
The Web UI catches up (PR #351)
With inline bodies threading through every backend mirror, the Library UI needed to learn the same trick. Two changes:
- Library agent detail renders
code_bodyas a monospace block alongsidesystem_prompt. A code-js agent in the Library is now fully visible — both the prompt and the code that runs. - The create/fork modal grows a "code body" textarea, shown only when
provider == "code-js"(no clutter for LLM-backed agents). The local-validation step requires a body for code-js agents — clearer feedback than the substrate's create-time refusal, which previously was the only failure mode.
Plus a backend touch: staticAgentDefJSON now includes code_body so a yaml-static code agent's body displays + forks in the UI exactly like a dynamically-created one. (The yaml-only struct tag wrinkle the #184 shadow-struct fix originally addressed, applied to one more field.)
And while we were in the UI, a small false-error cleanup: the "no tools cached" message on MCP servers had been telling the operator the server was unreachable or its handshake had failed any time discovered_tools was empty. That's wrong for the common case — a dynamically-registered or boot-unreachable server self-heals on first agent call (lazy registration), and its tools are simply not discovered until first use or an explicit rediscover. Reframed to admit an empty list is normal, and only points at the log when calls actually fail.
The v0.20.0 sibling close-out — discovery at ingestion
Yesterday's PR #343 made MCPServerDef create idempotent on matching content_sha256. It did not change when discovery happens. Before today, create just registered metadata (name, url, transport, headers) and discovered_tools stayed empty until an operator (or the consumer's startup script) ran a separate rediscover op. The Library UI showed "no tools cached" for newly-registered servers even when everything was working — the tools self-heal via lazy registration on first agent call, but the cached surface was blank.
PR #352 folds the tools/list handshake into create and fork at ingestion — same version (v1 carries discovered_tools, not v2 after a follow-up rediscover). Design choices worth naming:
- Best-effort. An unreachable peer doesn't fail the create — the server registers metadata-only and self-heals via the lazy resolver on first call. Bounded by the existing 30-second discovery budget.
- Promote-gated. Discovery dials by name, which the pool resolves to the about-to-be-active version's connection params. So a non-promoted fork is skipped (it can't be meaningfully dialed yet).
- Size-guarded. If the discovered tools would push the definition past
MaxDefinitionBytes, the row stores metadata-only — discovery in this case is deferred torediscover. - Opt out with
discover: falsefor a pure metadata registration (useful when an operator knows the upstream isn't reachable yet but wants the def row in place). discovered_toolsis not part ofcontent_sha256, so the dedup hash is unaffected by what comes back fromtools/list. The dedup contract stays clean.
PR #353 bumps @loomcycle/client to 0.20.0 and simplifies ensureMcpServer: discoveredToolCount now reads straight from the create response, with no need for a second round-trip. rediscover on every call becomes an explicit force-refresh escape hatch (re-read when the upstream's tool surface changed but the registration content didn't, so a plain re-register would dedup and keep the cached tools).
The same lesson as yesterday — but on the code-agent side
Yesterday's post closed four MCPServerDef static-vs-dynamic asymmetries. Today closes the same class on the code-agent side: code_body was the last AgentDef attribute that didn't ingest through the dynamic substrate, and every consumer who ran in cloud, container, or n8n interactive was working around it.
The pattern across both posts is the same:
- Every substrate primitive has two ingestion paths: yaml-loaded (operator-authored config that lives next to
loomcycle.yaml) and dynamically-created (the Def primitive, content-addressed, versioned, what the substrate is actually designed around). - Every seam between substrate and runtime must work the same on both paths. Yesterday that meant host allowlists, runtime resolution, tool advertising, content-addressed dedup, env expansion. Today it means hash-significant content fields (every producer must converge), Web UI display, validation at boot, compile-cache keying, the consumer-facing dedup contract.
- The side nobody exercises silently rots. Static-only or dynamic-only doesn't matter — whichever path is the less-trafficked one will quietly diverge, and the divergence will surface when the first consumer who needs the other path tries to use it.
For RFC J specifically, the v0.20.0 bottom line: a code-js agent now has zero dependency on a host filesystem when its body is supplied inline. Cloud deploys, container orchestration, and n8n interactive can each register a code agent over the wire with one typed call.
What this looks like from a consumer
The @loomcycle/client 0.19.0 → 0.20.0 ergonomics:
// At your service's startup (cloud / container / n8n)
import { LoomcycleClient } from "@loomcycle/client";
const lc = new LoomcycleClient({ baseURL, bearer });
// Register a code-js agent — no host FS, no agent_code//index.js
const { defId, version, changed } = await lc.ensureCodeAgent({
name: "summariser",
description: "Deterministic summariser — JSON in, JSON out",
allowedTools: ["Memory", "Channel"],
tier: "standard",
code: `
function run({ prompt }) {
const items = JSON.parse(prompt);
const recent = Memory.recall({ scope: "user", limit: 5 });
const summary = items.slice(0, 3).map(i => i.title).join(" / ");
Channel.publish({ name: "summaries", payload: { summary, recent } });
return { final_text: summary };
}
`,
});
if (changed) console.log(\`registered summariser v\${version}\`);
// changed: false on subsequent boots — server-side dedup absorbs the no-op
And on the MCP side, ensureMcpServer in 0.20.0 gets the tool count for free:
const { defId, version, changed, discoveredToolCount } =
await lc.ensureMcpServer({
name: "jobs",
url: "http://jobs-search-web:3000/api/mcp",
transport: "http",
headers: { Authorization: "Bearer ${run.credentials.jobs}" },
});
// discoveredToolCount is populated from the create response —
// no separate rediscover round-trip on first registration
Companion reading: Code as agent — and the design we replaced before shipping (the v0.16.0 RFC J writeup that originally shipped this primitive with the host-FS dependency), and We inverted a startup race — and found four static-vs-dynamic asymmetries to close (yesterday's MCPServerDef counterpart of today's story).