What it took to make loomcycle a first-class n8n citizen
The shape requirements are stricter than the protocol documents.
@loomcycle/n8n-nodes-loomcycle is the new community package you install in any self-hosted n8n instance to drop loomcycle in as a first-class workflow participant. Its agents become spawn-and-await steps. Its channels become publish-and-subscribe primitives. Its substrate (AgentDef / SkillDef / MCPServerDef) becomes manageable from inside the editor. And the bit that matters most: its LLM gateway becomes the Chat Model for n8n's own AI Agent node.
Four days from v1.0.0 to v1.2.0. Most of the shipped work was visible from the start. One piece was not, and most of this post is about that one piece: the LangChain Tools Agent integration story, which took three patches and ended with a defence-in-depth synthetic tool-call-id at every wire boundary.
First, the workflow shape that the package makes possible.
1. The workflow shape
Here is a typical producer workflow. A chat message arrives, a Researcher Agent runs against it (loomcycle spawns the agent, owns the loop, returns when done), a memory entry is written, a channel publishes the result for downstream consumption:
And here is the consumer workflow that picks up where the producer left off. A channel-message trigger fires when the publisher writes; the workflow reads the relevant memory entry and spawns the Editor Agent against it:
Two workflows, decoupled, talking via the loomcycle channel primitive plus shared memory. Neither workflow knows about the other. Both know about the channel. If you have designed a queue-driven system before, this shape is familiar. The difference is that the agents on both sides of the queue are loomcycle agents with their own policies, tool surfaces, OTel traces, and per-tenant quotas, and the queue is a loomcycle channel, with the same auth and audit surface.
Neither workflow knows about the other. Both know about the channel.
The two diagrams above were running off a development loomcycle while I was writing this post. The greens are real; so are the "1 item" edges. Here is the same workflow in motion:
Both workflows running end-to-end: chat message → researcher agent → memory write → channel publish → channel trigger fires → editor agent spawns against the stored memory entry. The loomcycle channel is the only coordination between them.
2. What ships in the package
Five categories of nodes. Every loomcycle wire-surface operation that makes sense in an n8n editor is reachable.
- Action nodes (sub-phases 2.1 + 2.2). Three umbrella nodes: LoomCycle: Run (spawn / list / cancel agent runs), LoomCycle: Memory (full CRUD over the Memory tool's underlying store: get / list / setEntry / appendEntry / deleteEntry, with full text and vector search), and LoomCycle: Channel (publish, subscribe-via-trigger, list, CRUD over channels). Three substrate admin nodes: LoomCycle: AgentDef, LoomCycle: SkillDef, LoomCycle: MCPServerDef: verify / create / fork / retire / list / get against the substrate described in last week's post.
-
Trigger nodes (sub-phase 2.3). Two of them: RunCompleted fires when an agent run finishes; ChannelMessage fires when a message is published to a watched channel. Both wired to loomcycle's existing SSE stream (
GET /v1/users/{user_id}/agents/stream), polished with the n8n trigger lifecycle: start / manual / stop methods, error recovery on disconnect, replay-from-last-event-id when n8n restarts. - Cluster sub-nodes (sub-phase 2.4). Four AI Agent cluster sub-nodes: LoomCycle Agent (spawn-and-await as a single tool call from inside an n8n AI Agent), and the three substrate sub-nodes again in cluster form. These plug into the standard n8n AI Agent's "Tools" slot so the agent can read / write loomcycle memory, publish to channels, and spawn loomcycle agents as tool calls.
-
Chat Model sub-node (v1.1.0). The fifth cluster sub-node, and the most important one for the broader story: LoomCycle Chat Model plugs into n8n's AI Agent's Chat Model slot, routing every model call the AI Agent makes through
POST /v1/_llm/chaton loomcycle's LLM Gateway. Existing n8n workflows that already use the AI Agent node keep working unchanged; the only thing that changes is which gateway sits behind the Chat Model dropdown. -
Example workflows (sub-phase 2.5). Six JSON workflow exports living in
examples/. chat-to-channel, scheduled-research-publish, channel-listener-editor, agent-spawn-with-tools, substrate-bootstrap, and memory-rag-pipeline. Two of them are the workflows pictured above. All six round-trip cleanly through n8n's import; a CI cron hits a live loomcycle once a day to make sure the examples do not bit-rot.
3. Three fixes that mattered
Three small things that took a patch each but mattered a lot:
The node picker. n8n 2.x changed how community nodes appear in the workflow editor's node-picker modal. Nodes that declare categories: ["AI"] on action nodes get filtered out of the general picker by default. Our v1.0.0 action node was invisible until you went hunting for it. v1.0.2 dropped the AI category from the action node (kept it on the cluster sub-nodes, where it belongs), and the picker found everything immediately. Sounds trivial. Hours of "is this even installed?" before we noticed.
The Agent dropdown. v1.0.0's LoomCycle: Run → Spawn populated its Agent dropdown by calling client.listUserAgents(), a plausible-sounding name for the wrong API. That endpoint returns currently-running and recently-completed agent instances for the caller, not the agent library. So new agents that had not been spawned yet did not appear, and freshly-installed loomcycles showed a dropdown reading "no running agents for this user", the empty-state placeholder, which several operators read as "the loomcycle library is empty", when really the library was fine and the n8n node was asking the wrong question. v1.0.3 swapped to client.listLibraryAgents() (new in @loomcycle/client 0.10.3, wrapping the Library v2 envelope endpoint). Each dropdown option now also carries a source-tag description showing whether that agent is STATIC (yaml-baked) or DYNAMIC (substrate).
Cluster sub-node execute(). n8n's cluster sub-nodes used in the AI Agent's Tools slot need an execute() method, not just a connection-time getTools() declaration. Without it, the AI Agent picks them up at definition time, lists them as tools, but throws when actually invoking them. v1.0.4 added execute() to all four cluster sub-nodes. The CI test that would have caught this earlier had to learn how to type makeExecuteContext.inputJson as IDataObject to satisfy n8n's index signature, which is a sentence I now sadly understand.
4. Why did the Tools Agent think the model had no tools?
The Chat Model sub-node, the v1.1.0 release that lets n8n's AI Agent put its model calls through loomcycle, was where everything got interesting. Plugging into n8n's AI Agent means plugging into LangChain's Tools Agent, because that is what n8n's AI Agent is using under the hood. And LangChain's Tools Agent has opinions.
v1.1.0: Chat Model sub-node lands. Wired to POST /v1/_llm/chat. Streams SSE on the way out. Implements the BaseChatModel.invoke / _generate / _stream interface from @langchain/core. Smoke test passes (a simple "respond with 'pong'" prompt).
v1.1.1: First real test against an n8n AI Agent with a bound tool. The agent runs but never attempts a tool call. The model is fine; the tools are in the prompt; the issue is that LangChain's Tools Agent decided the model does not support tools and went to a no-tools-fallback prompting strategy. Why? Because our BaseChatModel did not implement bindTools(). The Tools Agent does a feature probe on the model ("can I bind tools to you?"), and if the method is not present, it falls back to a tool-less ReAct prompt. We implemented bindTools(tools, kwargs); the agent now sees tool support; the agent starts attempting tool calls.
v1.1.2: The tool calls go out but every bindTools() invocation throws. TypeError: this.bind is not a function. The standard LangChain pattern for bindTools() is to call this.bind({ tools: kwargs.tools, ...kwargs }) to return a RunnableBinding that carries the tool definitions forward. this.bind exists on Runnable but not, apparently, on our chat model's prototype chain at invocation time inside n8n's module-loading environment (some combination of CommonJS vs ESM and n8n's vendored @langchain/core version did it; we never fully nailed the root cause inside n8n). The fix: stop relying on the this.bind lookup; construct RunnableBinding directly:
// Don't:
return this.bind({ tools: ..., ...kwargs });
// Do:
return new RunnableBinding({
bound: this,
kwargs: { tools, ...kwargs },
config: {},
});
That patch unblocks tool calls.
v1.1.3: Tool calls happen, but after each tool call returns, the AI Agent throws "Cannot read properties of undefined (reading 'id')" from somewhere deep in LangChain's message-shape detection. Spent a while on this. The Tools Agent inspects each message in the running conversation and routes them via shape detection (this is a HumanMessage, this is an AIMessage, this is a ToolMessage), and our streaming-tool-call emission was producing message chunks whose shape did not satisfy LangChain's detection. The proper way to do detection in modern @langchain/core is to read _getType() on the message instance: we had been relying on instanceof checks. Fixed the streaming tool-call emit to emit instances with the right _getType(); UX cleanup along the way to flush partial JSON args at the right SSE event. Tool-using flows go green for the first time end to end.
v1.1.4: The fix did not fully take on the operator's deployment. The exact same path that was green on our test n8n was rejecting messages with "messages[].tool_call_id is required" on theirs. Same code, different LangChain runtime. We dug into @langchain/core and found this:
// @langchain/core/messages/ai.js:178
if (!id || parsedArgs === null || ...) {
throw new Error("Malformed tool call chunk args.");
}
// → the tool call goes into invalid_tool_calls, NOT tool_calls
When the model emits a tool-call chunk with an empty id (which is allowed by some provider streaming formats, and was a default for our gateway in the simplest streaming code path), LangChain's chunk assembler does not throw immediately. It shunts the chunk into invalid_tool_calls. The Tools Agent then runs the tool anyway, but produces a ToolMessage reply with an empty tool_call_id. That ToolMessage hits the next round-trip; the gateway rejects it (messages[].tool_call_id required); the whole agent flow craters with a confusing error two steps removed from the actual cause.
The fix: mint a synthetic tool_call_id at every wire boundary where one would otherwise be empty. Outbound (model → wire): if the provider omitted an id, we set one before it leaves the Chat Model. Inbound (wire → tools): if a ToolMessage arrives with an empty id and there is an obviously corresponding tool call in the previous turn, we pair them. Both directions. Both defended. Tests at both seams. The bug stops being possible regardless of which provider is on the other end of the gateway. Hence "defence-in-depth synthetic tool_call_id at every wire boundary."
Make the id always-present at every seam you control, even when the protocol says you do not have to.
The generalizable lesson, take two: when you are plugging into someone else's runtime that does shape-routing on messages, the shape requirements are stricter than the protocol documents. The protocol says "id is optional in this streaming chunk"; the runtime's downstream assembler says "I will silently route this to a dead path if id is absent." Both statements are true. The correct response is to make the id always-present at every seam you control, even when the protocol says you do not have to.
5. v1.2.0: full Memory + Channel CRUD
v1.2.0 today rounds out the Memory and Channel action nodes from "publish / subscribe / read" to full CRUD: list, get, set, append, delete on memory entries; list, get, create, update, delete on channels. The bottleneck was not the n8n side. It was that @loomcycle/client's typed adapter did not yet wrap all the substrate ops on the /v1/users/{user_id}/memory and /v1/users/{user_id}/channels surfaces. Adapter v0.11.5 added the wrappers; the n8n nodes picked them up.
The reason to ship full CRUD as a unified release rather than dribble it out: the moment an operator can do spawn an agent, write a memory entry, subscribe to a channel, they reasonably expect to also be able to list the memory entries, delete the obsolete ones, and edit a channel's retention. Half-CRUD is a UX dead-end. Either the workflow author can manage the data or they cannot; "partially manage" is a footgun.
6. Installing it
In your self-hosted n8n instance, Settings → Community Nodes → Install, paste in @loomcycle/n8n-nodes-loomcycle. Restart n8n. A new "LoomCycle" entry appears in the node picker. The AI Agent's Chat Model dropdown gains a "LoomCycle Chat Model" option. The AI Agent's Tools section gains four LoomCycle cluster nodes.
Credentials: one n8n credential of type "LoomCycle API": base URL, bearer token. Same credential covers every node in the package; same bearer token covers calls to every provider configured on the loomcycle's other side.
Examples: clone the package repo (github.com/denn-gubsky/n8n-nodes-loomcycle), look in examples/, drag any of the six JSON files into n8n's editor via Workflows → Import from File.
7. What is next
Three things on the near horizon, in roughly priority order:
- Resource locator for the Agent dropdown. The current dropdown is a flat list. With a few dozen agents in a substrate it starts to need search and filtering (STATIC vs DYNAMIC, by tier, by tool family). n8n's resource-locator widget is the right primitive for this.
-
Streaming agent output to a webhook trigger. Right now
RunCompletedfires after the agent finishes. Some workflows want progressive output: the agent emitted its first paragraph, kick off a downstream summarizer immediately. The SSE stream supports it; we need the n8n side to fan it out cleanly. - n8n cloud compatibility. Self-hosted is in scope today; n8n cloud's community-node policy is stricter, and we want the package on the verified list. Submission process is mid-flight.
Whole development arc, again: four days, six PRs against n8n-nodes-loomcycle, three releases of @loomcycle/client to keep the adapter ahead of the n8n package, two minor loomcycle versions (v0.11.0 for the LLM Gateway, v0.11.5 for yaml-static channels) shipped to round out the surfaces the n8n nodes needed to plug into. The package is on npm under the @loomcycle scope; the loomcycle binary is on the GitHub releases page, the Homebrew tap, and as a Docker image. The two halves talk over one bearer token. The whole thing is Apache-2.0.
Companion writeups in this arc: Becoming OpenAI-shaped without becoming OpenAI (the LLM Gateway and shims this Chat Model sub-node plugs into), When the agent is in one container and its definition is in another (the substrate that lets the AgentDef / SkillDef / MCPServerDef admin nodes be useful from inside n8n's editor), and Scrubbing the model's incoming mail (the PostTool hook contract that any consumer, including an n8n-wrapped one, can register against the same loomcycle).
Two runtimes meeting in a shared editor each want the data in a shape the other considers optional. The shape documents say one thing; the runtime assemblers downstream of those documents say another. The right place to spend engineering care is at the seam, before the mismatch has had a chance to walk four hops away and surface as "undefined is not a function."
Four days, five patches, a dropdown that now reads the right library, and a tool-call id that is always-present no matter which direction it is travelling. Not glamorous. The sort of care that makes two runtimes feel, from the outside, like one.