Skip to main content
loomcycle
§ release · rfc ap + rfc bd

Agent teams: state machines that drive multi-agent work.

A loomcycle run is one agent doing one thing. That is the right shape for a lot of workloads: an operator asks a chat agent a question, a scheduled agent tails a queue, a headless job scores a batch. One agent, one turn budget, one exit.

A lot of real work is not that shape. Shipping a feature is architecture, then code, then review, then a PR. Publishing a marketing chunk is drafting, then editing, then a green light. A market scan is fan out to three sources, then consolidate. Each stage wants a different agent, a different prompt, a different tool grant. And each stage sometimes wants to send the work back a step. Review pushes back on the code. The editor asks for a revise.

Before this four-week arc you had two ways to model that on loomcycle. Option one: one big agent with the union of all the prompts, choosing the stage itself. That fails for the usual reasons: the prompt is a mile long, the tool grant is the union of every stage's tools, one bug in the routing prose derails the whole run. Option two: a conductor agent that spawns sub-runs via spawn_run, threads outputs into inputs, and decides the next step with more prompt prose. Better isolation, but the routing lives in an LLM's head and the state machine is invisible until it misfires.

RFC AP added a substrate primitive for the shape itself. A TeamDef is a state-machine graph over your agents. States name the work. Transitions name the outcomes (success, pushback:<reason>, conditional:<expr>). Each state has a handler: a specific agent, a parallel fan out to several agents plus a consolidator, or a terminal exit. RFC BD wrapped it in an LLM team lead that reads the graph as a map and drives it interactively. Four weeks of follow-up work (the editor, the diagram, parallel handlers, durable boards, gRPC and adapter parity, per-tenant credentials for the shell) turned it into something you can actually run production workflows on.

The short version. A TeamDef is a versioned, content-addressed, tenant-scoped substrate def whose payload is a state-machine graph: states, transitions (success / pushback:<reason> / conditional:<expr>, bounded by per-state max_iterations), and per-state handlers (agent | parallel + consolidator | consolidator | terminal). Two things run a team. op=run is a deterministic walk engine that carries the same-tenant identity through the graph and threads each state's output into the next. team/orchestrator is a bundled LLM agent that reads the graph as its map, drives the transitions, and keeps a human in the loop through the Interruption tool. The Web UI ships a teams board: a two-pane editor with a JSON graph on the left, a live Mermaid state diagram on the right (dry-run render on unsaved edits, no persist), a draggable splitter, a starter-template dropdown, and a Delete op that hard-removes a whole team. v1.19.0 filled the gaps: parallel fan-out plus consolidator execution in the deterministic engine, an optional Document-backed board that survives runtime restarts, an interrupt_on_cap flag that escalates an iteration cap overflow to a human instead of aborting, per-tenant credentials injected into raw Bash, and a TeamDef gRPC RPC plus @loomcycle/client and Python adapter methods.

The primitive

A TeamDef looks like every other def on loomcycle. Content-addressed. Tenant-scoped. Versioned. Create, fork, get, list, retire, promote, verify. Its definition is a graph:

{
  "entry": "architecture",
  "states": [
    { "state": "architecture", "handler": { "kind": "agent", "agent": "arch/planner" } },
    { "state": "implementation", "handler": { "kind": "agent", "agent": "code/writer" } },
    { "state": "review",        "handler": { "kind": "agent", "agent": "code/reviewer" } },
    { "state": "pr",            "handler": { "kind": "terminal" } }
  ],
  "transitions": [
    { "on": "success",           "from": "architecture",  "to": "implementation" },
    { "on": "success",           "from": "implementation","to": "review" },
    { "on": "success",           "from": "review",        "to": "pr" },
    { "on": "pushback:code-fix", "from": "review",        "to": "implementation" }
  ],
  "max_iterations": 5
}

Four handler kinds. agent runs one agent as a sub-run under the current identity, threading the previous state's output into the input. parallel fans out to several agents concurrently and gathers the results into a {"results":[...]} envelope. consolidator reads that envelope and emits the next edge as a signal: <edge> line. success advances. pushback:<reason> loops back. terminal ends the walk.

Three transition kinds. success is the default advance. pushback:<reason> is a labelled loop back to an earlier state; the reason is free text so an agent can say pushback:code-fix or pushback:revise and the graph routes it. conditional:<expr> is a lightweight boolean over run state, evaluated deterministically. Every loop is bounded by max_iterations, which is a per-state cap; a run that hits the cap either aborts (default) or escalates to a human (opt-in).

The engine is in internal/teamgraph (Parse, Validate, Sign; the content hash excludes cosmetic fields like colours and identity) and internal/teamrun (the walk engine plus the sub-agent runner). Signing is content-addressed for the same reason AgentDef and SkillDef are: a team you saved yesterday is the same team today, byte for byte, and diff-able across versions.

Two ways to drive a team

The primitive is designed to run two ways, both from day one. Which one you pick depends on how much routing you want an LLM to own.

Deterministic, via op=run. Give the tool a team name and an input. The walk engine takes it from there. It picks the entry state, spawns its handler, waits for the signal, picks the next edge, spawns the next handler, and so on until it hits a terminal or the iteration cap. No LLM makes routing decisions. The engine is pure Go, testable end to end, and every state's output is logged as an event so the Activity feed shows exactly which agent produced what. This is the shape you want for scheduled workflows and headless pipelines.

Interactive, via team/orchestrator. A bundled LLM agent that takes a team name and reads the graph as its map. It knows the states, the transitions, the handlers, and the current position. It spawns handlers itself (via spawn_run), decides which transition matches an outcome, and drives a Document task board that shows the human where the team is. It's steerable. The human can interrupt mid-run to reroute, or answer a question the orchestrator asks. This is the shape you want when the workflow needs judgement between states, or when the human wants to sit next to the team and course-correct.

Same graph. Same handlers. Different driver. The team's author writes one TeamDef and both drivers run it correctly. That symmetry is why the primitive is a state machine rather than a script: a state machine is a data shape, and data can be interpreted two ways without duplication.

The teams board

The Web UI got a teams board in v1.17.1, and it grew across five follow-up releases into the workspace you see now: a two-pane editor with the graph JSON on the left and a live-rendered Mermaid state diagram on the right, divided by the same draggable splitter that Agents, Memory, and Schedules use.

Loomcycle Web UI teams board showing the marketing team: JSON editor on the left with entry=draft and three states (draft, edit, published), Mermaid state diagram on the right showing draft → edit → published with a pushback:revise loop from edit back to draft.
The teams board editing the marketing team. Left: the graph JSON. Right: the live Mermaid diagram. The pushback:revise edge loops the editor's request back to the drafter.

Selecting a team loads its stored graph JSON into the editor and renders its diagram from the persisted colour scheme. Edit the JSON, click Refresh diagram, and the runtime does a dry-run render. It parses and validates the unsaved graph, generates a fresh Mermaid diagram, but does not persist anything. Syntax errors surface in the editor. Graph-validation errors surface on the diagram. Click Save new version to fork and promote the graph; the versions history preserves the earlier one.

The dry-run render is one small additive field on the existing render_diagram op. Pass an overlay with an inline graph and the server takes the whole authoring path (Parse, Validate, RenderMermaid) but never touches the store. Existing render-by-name still works unchanged. It's the same shape as the interactive-terminal endpoints: the wire surface stayed narrow, one field, no schema migration.

Loomcycle Web UI teams board showing the sdlc team: JSON editor on the left with states architecture, implementation, review, pr, transitions include a pushback:code-fix loop, Mermaid state diagram on the right showing the same graph with the pushback edge going from review back to implementation.
The sdlc team. Architecture → implementation → review → pr, with a pushback:code-fix edge that loops the reviewer's request back to the writer. Bounded by max_iterations: 5.

Team definitions are runtime-only: a bundle can't pre-create them because they're per-tenant state. The board's + create team action is the missing "add" path: a name plus a graph-overlay JSON editor that posts to TeamDef op=create, with a starter-template dropdown that pre-fills the SDLC and marketing example graphs. The templates reference the team-examples bundle's handler agents, so a freshly created team actually runs when that bundle is loaded.

A quiet detail from v1.18.1 worth naming: the editor's load path had a bug where the server returned the graph as a json.RawMessage (which arrives already parsed as a JS object) and the editor tried to JSON.parse it, stringifying the object to "[object Object]" and throwing. Create was unaffected because it starts from a template. Selection is what broke. Same release added a tenant-scoped delete op that hard-removes a whole team (all versions plus the active pointer, one transaction), mirroring the delete op on AgentDef.

Parallel handlers and the consolidator

v1.17.0 shipped the graph and the walk engine, but the engine only handled linear teams. A state whose handler was parallel would validate fine (the graph model has always had parallel handlers) but the run engine refused to enter it. v1.19.0 filled that gap.

A parallel handler fans out to its agents: concurrently under a bound. Three wait modes: wait: all (default; wait for every branch), wait: any (return as soon as one succeeds), wait: at_least:N (return when N branches have succeeded). Each branch runs as its own sub-run under the current identity (same tenant, same run depth, same token budget contribution) and its output lands in a slot of the results envelope.

{
  "state": "market-scan",
  "handler": {
    "kind": "parallel",
    "wait": "all",
    "agents": ["scan/emag", "scan/altex", "scan/pcgarage"]
  }
}
{
  "state": "consolidate",
  "handler": {
    "kind": "consolidator",
    "agent": "market/consolidator"
  }
}

The consolidator is the trick. When the parallel state finishes, the walk engine collects the three outputs into a {"results":[...]} envelope and passes it as the input to the consolidator's handler. The consolidator's job is to look at the envelope, decide the outcome, and emit a signal line. The engine watches for a line matching signal: <edge> in the consolidator's final output: signal: success to advance, signal: pushback:<reason> to loop back. So pushback routing composes with parallel fan-out. A market scan that returns inconsistent results can loop back to a re-scrape.

The per-state iteration cap still bounds every cycle. If the consolidator emits pushback:re-scan six times in a row and max_iterations is five, the sixth attempt refuses to enter and the run terminates (or interrupts, if the flag is set).

No wire change. The graph schema already had parallel handlers because RFC AP anticipated this landing. The change is entirely in internal/teamrun: the walk engine grew a fan-out entry, a completion barrier, an envelope builder, and a consolidator dispatch. Every existing team continues to run identically.

Durable boards and interrupt-on-cap

A team that takes hours to walk needs to survive a runtime restart. A team that hits an iteration cap in the middle of a shift needs to ask a human what to do, not silently abort. v1.19.0 added both, as opt-in flags on op=run.

Durable, resumable boards. Bind board_chunk_id to a Document chunk and the run's state transitions each write chunk.status as the current state name. If the runtime restarts, or if you pause the run and pick it up later, the walk engine reads chunk.status, resumes from that state, and continues. The chunk's history keeps every transition. You can audit what state a team was in at any point in the past. Runs that don't set board_chunk_id are byte-identical to the pre-v1.19.0 shape: no chunk writes, no reads, no observable difference.

Interrupt-on-cap. When a per-state iteration cap trips, the default is to terminate the run. With interrupt_on_cap: true the walk engine instead raises an Interruption, the same primitive the chat agents use to ask the human questions mid-turn. The human sees the state that overflowed, the reason it looped, and three actions: continue (bump the cap by one and let the state run again), reroute:<state> (skip to a specific downstream state), abort (terminate the run). An unanswered ask, or a declined one, still terminates; the cap guarantee holds. It's a safety valve that turns a hard failure into a decision point.

Both flags are additive. Both compose with the LLM orchestrator, which itself uses the Interruption tool to ask its questions, so an orchestrator-driven team with interrupt_on_cap: true gets two ways to reach the human: an orchestrator-authored question and a walk-engine-authored cap escalation. They share the same UI surface.

Per-tenant credentials for the shell

Software teams need to clone a repo and open a PR. In loomcycle that flows through Bashbox's git and gh fallback into a raw shell, and until v1.19.0 the shell child inherited the operator's environment. Which meant: your GITHUB_TOKEN. Which meant: the team can push under your identity, and every tenant that uses the software team's bundle shares that identity.

LOOMCYCLE_BASH_ALLOWED_CREDS is the fix, and it mirrors the pattern that landed for the Bashbox host-command fallback in RFC BD's phase 2. Configure the tenant's CredentialDefs (they're already encrypted per-tenant, decrypted at inject time, registered with the secret redactor so the model never sees them). Add the credential name to the allowlist. Raw Bash's child now runs with that credential injected into its env under the credential's declared key. Tenant-isolated. Opt-in per credential. No leakage across tenants. No existing Bash protection weakened. The child still runs under the same user, in the same working directory, with the same tool allowlist.

A software team on tenant A gets tenant A's GITHUB_TOKEN. Tenant B's software team, running the same bundle, gets tenant B's. The operator never puts their key on the host.

Surface parity across gRPC, TypeScript, and Python

TeamDef shipped in v1.17.0 as HTTP plus MCP only. The gRPC and adapter surfaces caught up in v1.19.0. The gRPC RPC follows the same op-discriminated shape as the other substrate defs: ScopeTenant, one method, an op enum that selects create / fork / get / list / retire / promote / verify / render_diagram / run. Same shape as AgentDef and SkillDef, which lets the connector-based adapter pattern reuse its plumbing verbatim.

@loomcycle/client gained listTeams, renderTeamDiagram (with the overlay dry-run parameter), getTeamDef, createTeam, forkTeam, deleteTeam, and runTeam. Python got the same. Both bumped to 1.19.0. So a team's diagram can be rendered from a Node script, its graph can be edited from a Python notebook, and its op=run can be driven from either, with the exact same wire contract.

Where this lives in the substrate

TeamDef is a peer of AgentDef, SkillDef, VolumeDef, ChannelDef, CredentialDef, SchedulesDef, and MCPServerDef. It sits at the same layer, uses the same content-addressed signing, participates in the same snapshot/restore, is enforced by the same tenant scopes. There is no "team plane". A team is just another def, and its walk engine uses the ordinary sub-run machinery. Every capability the sub-agents already had (Memory, Channel, Document, Path, Interruption, Bashbox, MCP servers, credentials) is available inside a team's handlers unchanged.

What TeamDef adds is structure. Before, a multi-step workflow was a conductor agent's private prompt. Now it's a data shape you can render, diff, version, share as a bundle, run headless, and read as a picture. The two starter teams in the team-examples bundle (SDLC: architecture → implementation → review → pr, and marketing: draft → edit → published) are readable in fifteen seconds because they're state machines, and everyone who has ever drawn a workflow on a whiteboard already knows how to read them.

The primitive is also small. internal/teamgraph and internal/teamrun together are a few thousand lines. The graph schema is a page of JSON. The Web UI editor is one route. The primitive is deliberately narrower than most workflow engines because loomcycle already gave it identity, secrets, tools, memory, and a sub-run runner. TeamDef only had to be the routing plane.

What's next

A few things are queued for the v1.19.x and v1.20.x lines. Auto-branching, where a team's parallel handler grows a shape where the branches themselves are chosen at runtime by a consolidator (fan out to a dynamic subset). Team-of-teams, a handler kind that runs another TeamDef as its sub-run, so you can compose small teams into larger ones. Board-side UX for humans watching an orchestrator-driven team, with the interruption prompts and the state-transition timeline in one pane. And a Skill that lets a chat agent create a TeamDef from a natural-language description, matching the shape of the SkillDef author skill from RFC BA.

Companion reading: RFC BA on-demand skills (the same substrate discipline applied to model context), RFC BC client-executed tools (how LoomBoard's Chrome extension actuates from an agent's tool call), the CredentialDef post (how per-tenant secrets flow into an agent's shell, the same plumbing v1.19.0 extended to raw Bash), and the agent-teams help topic for the operator-side reference.