Loop engineering: conditional agentic loops¶
A CAL (Conditional Agentic Loop) is a signed, BPMN-like process document that connects multiple cartridges — each referenced by content hash or staffed into a role slot — and declares who may do what, when, under which structured conditions, and what counts as done; its companion, RAC (Required Available Context), declares the knowledge that must be present as descriptions only, never content.
Schema: cal.schema.json (acx.cal/1) and cal-skillset.schema.json (acx.cal-skillset/1). Implementation: src/cal.mjs.
Share an agent team in 60 seconds¶
# Validate the portable contract and required public metadata.
acx workflow lint my-team.cal.json --publish
# Sign the exact graph. Keep the emitted .key.pem private and out of git.
acx workflow sign my-team.cal.json \
--publisher io.github.yourhandle \
--out my-team.signed.cal.json
# A recipient verifies before trusting, staffing, or executing anything.
acx workflow verify my-team.signed.cal.json
# Then — separately — it checks whether its own roster can fill every slot.
acx workflow ready my-team.signed.cal.json --cartridges ./roster
This separation is the portable contract: the same signed workflow can be shared through git, HTTP, or
OCI; each receiving studio supplies its own cartridges. lint, sign, verify, inspect, and ready
are read-only with respect to agent work — none dispatches tasks or evaluates arbitrary code.
One agent vs. many¶
The loop & context policy block engineers the loop inside a single cartridge: maxTurns, the plan → gather_context → act → verify → reflect cycle, stop conditions, verification. A CAL is the layer above it — the loop between cartridges. Where the policy answers "how does this one agent iterate?", the CAL answers "which agents, in which order, gated by what?"
flowchart TB
subgraph CAL["CAL — multi-agent loop (a standalone .cal.json)"]
A["cartridge A<br/>(own loop policy)"] --> B["cartridge B<br/>(own loop policy)"]
B --> C["cartridge C<br/>(own loop policy)"]
C -->|"condition not met"| B
end
Each participant still runs its own signed single-agent loop when it takes a task; the CAL only decides whose turn it is and when the process ends.
Control flow vs. information architecture
A CAL says what happens next. An Agent Graph says who owns the context, who can direct whom, where reports return, and where separate loops meet. Use the graph to describe durable knowledge and reporting relationships around one or several CALs; it never dispatches their tasks.
The data model, connected¶
| Object | What it is | Where it lives |
|---|---|---|
CartridgeRef |
A participant: an alias bound either to an exact cartridge by hash (romDigest = the signed ROM manifest hash) or to a slot (any cartridge matching role / minLevel / capabilities may staff it) |
cal.participants[] |
RacItem |
Required Available Context: a description of knowledge that must be present (kind, description, required, a check for how to confirm it, optional okf descriptor). MUST NOT carry content |
cal.rac[] |
CalNode |
A task (agent alias + requires{skills, capabilities, rac} + a completion condition), a gateway (exclusive / parallel / inclusive), or an event (start / end / stop / handoff / timer) |
cal.nodes[] |
CalEdge |
A transition {from, to, when} with a structured condition — no expression eval |
cal.edges[] |
Cal |
The whole document: discovery metadata + participants[] + rac[] + variables[] + limits + start + nodes[] + edges[] + integrity |
a standalone .cal.json (published at registry/cals/<publisher>/<id>/<version>.cal.json) |
CalSkillSet |
The per-cartridge participation declaration: which roles it plays, which other agents it references by romDigest, which CAL ids it takes part in |
inside each cartridge at rom/cal/skillset.json (ROM-signed) |
Everything connects: a CalNode names a participant alias; the alias resolves via a CartridgeRef; the node's requires.rac points at RacItem ids; CalEdge conditions read the CAL's declared variables or RAC availability; and each cartridge advertises its side of the contract in its own signed CalSkillSet.
For public discovery, a publishable workflow also carries a stable id, SemVer version, human-readable
name and description, SPDX license, authors[], and tags[]. Unknown top-level fields are rejected;
vendor additions belong under reverse-DNS keys in extensions.
Participants: hash or slot¶
A participant is a stable alias inside the process; how it binds to a real cartridge is a per-participant choice.
romDigest is the content-addressed ROM manifest hash — the same value the DSSE/ed25519 signature covers (see signing & trust). Pinning by hash means: this exact signed brain, byte-for-byte, or nothing. Resolution matches card.romHash === romDigest.
{ "alias": "builder", "bind": "slot",
"slot": { "role": "devops_engineer",
"minLevel": { "acxLevel": 15 },
"capabilities": [{ "taskType": "build-dag" }] } }
Any cartridge whose card matches the constraints may fill the alias. role matches the card role; each
required capability must appear as a capability taskType; minLevel.acxLevel gates
only on a cryptographically resolved, independently issued credential.
A declared level or an unresolved credential never satisfies minLevel. When several cartridges
match, the resolver ranks proven candidates without turning a stored claim into evidence.
Set "required": false on a participant to make an unstaffed slot a warning rather than a readiness failure.
RAC: required available context — descriptions only¶
A RAC item declares knowledge that must exist before the loop runs. The rule is absolute and doubly enforced — the schema ("not": {"required": ["content"]}) and the linter (rac … MUST NOT carry content — description only) both reject a RAC item that embeds content:
{ "id": "infra-arch", "kind": "terraform", "required": true,
"description": "Terraform modules describing the network + IAM architecture (structure only, not the .tf contents).",
"check": { "type": "file-glob", "hint": "infra/**/*.tf" } }
kind∈wiki | code-map | infra | terraform | api-spec | dataset | runbook | custom.description(required) — what the knowledge is, in prose.check— how a host or operator confirms availability:file-glob,url,mcp-resource, ormanual, each with ahint.okf(optional) — an Open Knowledge Format descriptor of the knowledge artifact, metadata only; see knowledge & OKF for how RAC's description-not-content rule and OKF's "just markdown, metadata about data" premise are the same idea.
Why descriptions and not content
The CAL is a shareable process definition; the knowledge it depends on is often private (infra layouts, warehouse schemas, internal wikis). Declaring that the knowledge must be present — with a check to confirm it — lets the same CAL run against any environment that can satisfy the description, without ever leaking the environment into the document. Edges can even branch on it: the condition {"racAvailable": "warehouse-schema"} is true only when the host reports that item available.
Nodes: task, gateway, event¶
{ "id": "build", "type": "task", "agent": "builder",
"action": "Build + backfill the Airflow DAG",
"requires": { "capabilities": ["build-dag"], "rac": ["infra-arch", "warehouse-schema"] },
"completion": { "type": "verification",
"commands": ["lint", "dag:validate", "test:touched"],
"passIntent": "DAG parses, tasks wire up, touched tests green" } }
A task binds a participant alias plus requires{skills, capabilities, rac}. The linter checks coverage against the bound cartridge: every required capability must be among the agent's capabilities (taskType match), every required skill among its SKILL.md skills, every RAC id declared in cal.rac[]. Every task must declare a completion:
completion.type |
Done means |
|---|---|
skill-scripts |
the named skill scripts[] ran to completion |
verification |
the commands[] passed, judged against a prose passIntent |
guardrail |
a guardrail of the given kind fired (e.g. a milestone) |
artifact |
the task produces the named artifact |
Every task can also declare the host-facing safety contract:
sideEffects:none | workspace | externalapproval:never | on-request | always
A host may always impose a stricter approval or resource policy. A workflow never grants authority the host or operator did not already provide.
exclusive (one outgoing edge wins), parallel (all fire), inclusive (every edge whose condition holds fires) — the standard BPMN trio.
start | end | stop | handoff | timer. A handoff event is where the multi-agent loop meets the single-agent handoff / OperatorCommandReport contract.
Edges: structured conditions, no eval¶
An edge is {from, to, when}. Omitting when means the transition always fires. The condition grammar is deliberately closed — five shapes, evaluated by a ~20-line interpreter (evalCondition in src/cal.mjs), with no expression language and no eval:
| Shape | Example | True when |
|---|---|---|
| always | {"always": true} |
unconditionally |
| comparison | {"var": "review.outcome", "op": "eq", "value": "completed"} |
dotted-path variable compares (eq ne lt gt le ge in) |
| all | {"all": [c1, c2]} |
every sub-condition holds |
| any | {"any": [c1, c2]} |
at least one holds |
| not | {"not": c} |
the sub-condition fails |
| rac | {"racAvailable": "code-wiki"} |
the host reports that RAC item available |
This is the same design stance as the loop policy's structured stop conditions: conditions are data a host evaluates, never code a host executes.
Bounded graph semantics¶
Portable validity is stricter than “all referenced ids exist”:
- every node must be reachable from
start; - every reachable node must have a path to an
endorstopevent; - terminal events must not have outgoing edges;
- non-terminal nodes must have at least one outgoing edge;
- duplicate ids and duplicate edges are rejected;
- multiple branches from a non-gateway node must all be conditional;
- every cyclic graph must declare a positive
limits.maxSteps; limits.maxDurationMsandlimits.maxParallellet hosts impose further bounds.
These are structural guarantees, not a claim that the graph will finish under every real-world condition. A conformant runtime still enforces the limits and fails closed when a completion contract is not met.
Worked example: ship-a-feature¶
The repository ships a real CAL at
registry/cals/io.github.lboel/ship-a-feature/1.0.0.cal.json: an architect designs, a builder builds, a
security sentinel reviews, and an exclusive gateway loops back to build until that review passes.
flowchart LR
design["design<br/><small>architect · artifact: design-doc</small>"] --> build["build<br/><small>builder · verification</small>"]
build --> review["review<br/><small>reviewer · guardrail</small>"]
review --> gate{gate<br/>exclusive}
gate -->|"review.outcome == completed"| done(("done"))
gate -->|"not completed"| build
All three participants are slot-bound (architect, devops_engineer with minLevel.acxLevel: 15,
security_expert), so the same document can be staffed from a receiving roster — or you pin any of them
by romDigest. Readiness fails closed when the roster lacks an independently proven level:
$ node --experimental-sqlite src/cli.mjs workflow lint \
registry/cals/io.github.lboel/ship-a-feature/1.0.0.cal.json --publish
verdict: VALID ✓ — portable structure and publication profile pass
$ node --experimental-sqlite src/cli.mjs workflow ready \
registry/cals/io.github.lboel/ship-a-feature/1.0.0.cal.json \
--cartridges ./my-roster
acx workflow ready <cal.json> [--cartridges <dir>] recursively scans .acx files, resolves every
participant, prints the RAC checklist (□ required, · optional) and flow, and exits non-zero when
anything is unresolved or uncovered. The last command's verdict depends on ./my-roster. A stored or
self-declared level is never enough for minLevel; credential signature, issuer, revocation, evidence,
and ROM binding must resolve. acx cal remains an alias.
The registry also ships
registry/cals/io.github.lboel/research-council/1.0.0.cal.json, a task-general example: a scout and
skeptic work in parallel, then an editor with approval: always synthesizes a decision brief. It
demonstrates that ACX is not tied to coding workflows.
Integrity, identity, and trust¶
A signed workflow carries an acx.workflow-signature/1 integrity block. The signer:
- removes the top-level
integrityfield; - canonicalizes the remaining document with RFC 8785/JCS;
- addresses it as
sha256:<digest>; - binds the digest, workflow
idandversion,publisherId, andsignedAtin an in-toto Statement v1; - signs the Statement as a DSSE envelope with Ed25519.
Verification recomputes all of those bindings. A valid signature from an unknown key is portable; a
matching, namespace-proven public-key registry entry is trusted; a digest, signature, identity, or
key-compromise mismatch is tampered and exits non-zero. The private key is never embedded.
acx workflow inspect registry/cals/io.github.lboel/ship-a-feature/1.0.0.cal.json
acx workflow verify registry/cals/io.github.lboel/ship-a-feature/1.0.0.cal.json
The public profile uses media type application/vnd.acx.workflow.v1+json. Its DSSE payload type is
application/vnd.in-toto+json, matching the cartridge signing model.
The immutable published identity is publisherId + id + SemVer + digest. Changing signed bytes requires
a new SemVer and path. A remix can preserve the parent's publisher, id, version, and digest inside signed
lineage, while receiving its own publisher identity and signature.
Interoperability profile¶
ACX defines a portable workflow artifact, not another transport or tool-calling protocol:
- An A2A Agent Card can advertise ACX support through
AgentExtension; ACX supplies the signed team graph and staffing constraints. - MCP tools can satisfy task capabilities at runtime; ACX declares roles, required capabilities, context, completion, and control flow.
- An OCI 1.1 artifact manifest can transport the JSON as a typed artifact; git and HTTP work equally because verification covers the bytes, not the transport.
These mappings are informative. The normative ACX artifact remains usable without A2A, MCP, or OCI.
The per-cartridge CalSkillSet¶
The CAL is a standalone document, but each cartridge also carries its own side of the multi-agent contract — a small, ROM-signed participation declaration at rom/cal/skillset.json (acx.cal-skillset/1), pointed to by the acx.cal_skillset metadata key:
{
"schemaVersion": "acx.cal-skillset/1",
"plays": [
{ "role": "devops_engineer",
"providesCapabilities": ["build-dag", "deploy-service"],
"canComplete": ["airflow-dag-authoring"] }
],
"references": [
{ "alias": "reviewer", "romDigest": "sha256:…", "role": "security_expert" }
],
"processes": ["ship-a-feature"]
}
plays— the roles this agent can fill, the capabilitytaskTypes it provides, and the skills itcanComplete(including skill scripts).buildCalSkillSet()derives this automatically from the cartridge's capabilities and skills tables.references— other agents this one hands off to, by content hash — the sameromDigestbinding as a hash participant, so a cartridge's collaborators are pinned inside its own signed ROM.processes— CAL ids this agent participates in.
Because the file lives in ROM, it is covered by the content-addressed manifest and the DSSE/ed25519 signature: an agent's declared collaborations cannot be swapped out after signing. Every cartridge carries this alongside the clean package spec (rom/package-spec.json, acx.package-spec/1) and the fixed memory schema (acx.lance-memory/1); acx spec validates all of them.
Visual authoring¶
Open the static ACX Studio to start or remix a workflow. It edits portable JSON
locally, can import a signed parent, removes the old integrity, and adds digest-pinned lineage before
export. It never receives a private key or writes to the registry.
Validate, sign, and prepare the exported draft explicitly:
acx workflow lint my-loop.cal.json --publish
acx workflow sign my-loop.cal.json \
--publisher io.github.yourhandle \
--out my-loop.signed.cal.json
acx workflow verify my-loop.signed.cal.json
acx share workflow my-loop.signed.cal.json --dry-run
acx builder --port 8799 serves that same static Studio locally. Export remains a browser download; there
is no draft-write API. Publication always crosses the CLI and PR verification boundary.
Generating a loop from a codebase¶
acx init bootstraps both sides:
$ node --experimental-sqlite src/cli.mjs init my-agent --role backend_dev
$ node --experimental-sqlite src/cli.mjs init --from-code . --out ./crew
The first form scaffolds a single fillable agent package. The second (src/init.mjs) analyzes a codebase and generates an agent set plus a CAL plus RAC: it inspects package.json dependencies, *.tf files, Dockerfiles/CI, test layouts, Python/Airflow markers, and SECURITY files to propose roles, then emits agents/<role>/ packages, a cal/from-code.cal.json wiring them together, RAC items describing the code knowledge (e.g. a code-wiki item: "An LLM-readable wiki / knowledge map of the codebase"), and a README. Run on this repository itself, it detects backend_dev, devops_engineer, and qa_engineer plus a code-wiki RAC.
Honesty: what the reference implementation does and does not do
- The
--from-codeanalyzer is a heuristic scaffold — no LLM is invoked. It proposes roles and RAC descriptions from file-system signals; you fill in the specifics before exporting real cartridges. acx workflow lintis a portable structural linter,workflow readyis a local roster resolver, andevalConditionis a real structured-condition interpreter — but a runtime that drives the loop (dispatching tasks to hosts, enforcing approvals and limits, advancing edges as completions land) is host-side, like the single-agent loop-policy evaluator.- RAC items and OKF descriptors are metadata only, always. If you find yourself wanting to put content in one, that content belongs in a knowledge source the
checkcan point at — see knowledge & OKF.
See also¶
- Loop & context policy — the single-agent loop each participant runs internally.
- Capabilities — the
taskTyperecords that slot constraints andrequires.capabilitiesmatch against. - Provable level — why
slot.minLevelgates on something an agent cannot fake. - Knowledge & OKF — the Open Knowledge Format mapping for RAC's
okffield. - Signing & trust — the
romDigestand DSSE envelope that hash-bound participants and theCalSkillSetrely on.