Skip to content
Like what we’re building? Star on GitHub

Bundles — .botz packaged workflows

A bundle is a deterministic ZIP archive that ships a workflow (main.bot) alongside the resources it depends on — Claude Code skills, reusable prompts, default attachments, a manifest (legacy tar.gz bundles are still read for back-compat). The result is a single .botz file you can email, commit, or drop into S3, and that any iterion install can run with one command.

bash
iterion bots create  my-bot         # scaffold
$EDITOR bots/my-bot/main.bot        # write your workflow
iterion bundle pack  bots/my-bot    # → my-bot.botz
iterion run          my-bot.botz    # run it

Why bundles

A plain .bot is one file. As soon as the workflow needs adjacent resources — a project-local Claude Code skill, a reviewer prompt, sample input PDFs — those files have to live on every machine the workflow runs on. Bundles solve that: everything ships together, with a stable content hash that lets two machines extracting the same bundle reuse the same cache slot.

Bundles are also the unit of distribution we expect for shared workflows (templates, examples, organisation-internal recipes).

Shared subbot dependencies

A bundle can export workflows for another project bundle to call with a bot:// URI. The consumer declares the dependency and exports it uses in its manifest, while a project-root bots.lock pins the source revision and exact bundle content hash. The materialized .botz/<name> tree is a generated, read-only consumer copy.

bash
# Restore all pins after cloning a consumer project.
iterion bots sync

# After committing a source-bundle change, advance one pin and rematerialize it.
iterion bots update shared-planner

Edit the source bundle, never .botz. bots sync only restores the existing pin; it does not discover a newer revision or rewrite the lock. bots update does both. Local Git sources must be clean unless the operator explicitly uses --allow-dirty, whose resulting lock is not reproducible by another checkout.

Quick start

bash
# 1. Scaffold a layout under ./bots/my-bot.
iterion bots create my-bot

# 2. Edit main.bot, drop skills/prompts/attachments as needed.
$EDITOR bots/my-bot/main.bot
echo "# my skill" > bots/my-bot/skills/probe.md
echo "Hello {{vars.topic}}" > bots/my-bot/prompts/helper.md

# 3. Build the deterministic archive.
iterion bundle pack bots/my-bot
#  → my-bot.botz   (next to the source dir)

# 4. Run it like any workflow file.
iterion run my-bot.botz
iterion run my-bot.botz --preset quick    # named preset from main.bot

Layout

my-bot/
├── main.bot           # required — the workflow source
├── manifest.yaml      # optional
├── README.md          # optional, for human readers
├── skills/            # optional — Claude Code skills
│   └── probe.md
├── prompts/           # optional — reusable .md prompts (flat: a file in a subdirectory is not read)
│   └── helper.md
├── lib/               # optional — fragments main.bot imports (`import "lib/nodes.bot"`)
│   └── nodes.bot
├── attachments/       # optional — default values for `attachments:` block
│   └── logo.png
└── presets/           # optional — file-based presets ("sous-bots")
    └── sre.md
EntryPurpose
main.botThe workflow source. Must live at the bundle root.
manifest.yamlBundle metadata (name, version, schema_version, optional attachments: map). Optional.
skills/Claude Code skills. Mirrored into <workDir>/.claude/skills/ at run time. Workspace files always win on collision (warn-logged).
prompts/Reusable .md prompts. Each file is auto-registered with name equal to the filename stem — prompts/helper.md makes system: helper resolvable from main.bot. Workflow-declared prompts always win on collision. An {{include "x.md"}} inside one resolves next to that file, inside prompts/.
lib/Fragments of a bot in several files: .bot files main.bot imports (import "lib/x.bot", dsl.md), merged with it into one program. Never a bot of their own; a bundle that imports declares requires.iterion at or above v3.145.0 (C252, 409).
attachments/Default binary inputs the manifest can map to declared attachments: entries. Runtime uploads (Launch modal, cloud) override these.
presets/File-based presets ("sous-bots"): each presets/<name>.md (YAML frontmatter + markdown body) is a named launch-time specialization selected with --preset <name>, layering variable overrides + a system-prompt bias + skill hints onto the bot.

Manifest schema

Assistant authoring perimeter

Project bots may expose an explicit companion-file write perimeter to a conversational assistant:

yaml
authoring:
  editable_files:
    - {scope: bundle, path: checks/review.bot}
    - {scope: workspace, path: scripts/review.py}

Paths are explicit, relative, normalized and unique; absolute paths, traversal and globs are rejected. bundle is relative to the manifest; workspace is relative to the active project and is forbidden for the universal catalog under bots/. This declaration grants no model tool and no read access. It only bounds Studio-owned preview/commit requests, which still require optimistic-concurrency checks and the operator's Assistant action policy. See The assistant dock.

yaml
name: my-bot              # human-friendly identifier (display only)
version: 0.1.0            # free-form, semver recommended
description: One-liner.
author: Your Name <you@example.com>
schema_version: 1         # required; iterion refuses unknown versions

# Optional: the engine this bundle needs (see below)
requires:
  iterion: ">= 3.112.14"

# Optional: map workflow attachment names → files inside attachments/
attachments:
  logo: branding/logo.png
  spec: docs/spec.pdf

# Reserved for future minor extensions (additive). Unknown keys are
# tolerated under `compat:` so newer bundles don't break older iterion.
compat:
  some-future-key: 

The current schema version is 1. Bundles that omit schema_version are treated as v1. iterion refuses any other value with an explicit upgrade hint.

requires: — the engine contract

A bundle and the engine that evaluates it travel separately: a bundle is pushed or checked out in a second, an engine is installed or deployed. When the bundle uses something the engine does not have, the workflow compiles — a call to an unknown builtin parses generically — and dies at its first evaluation. Measured 2026-09-06: a bot pushed as a platform override used the variadic min/max of a newer release while the runners ran an older image; the run failed at compute "delivery_reserve" and auto-resumed in a loop.

requires: is the declaration that closes it. One key today:

yaml
requires:
  iterion: ">= 3.112.14"    # or a bare "3.112.14" — the operator is optional

The grammar is deliberately total: >= (or nothing) followed by a dotted numeric version with an optional leading v. Every other shape is a manifest parse error< 1.2, ^1.2, ~> 1.2, == 1.2, 1.2-rc1 — and so is an unknown key under requires: (the manifest decoder is strict). A requirement iterion cannot read is never a requirement iterion ignores; a build too old to know the key refuses the whole manifest rather than dropping the contract.

It is a version FLOOR rather than a feature list because the only channel a deployment has to its runners is a version string — the build each runner stamps on the runs it executes. The cost is named: a fork or a backport carrying the feature under a different version reads as too old, and a build with no orderable version (dev, a fork's scheme) makes the check inconclusive, which is reported, never passed in silence.

Two things a bundle's sources use ask for a floor by themselves: a syntax profile above 1 (dsl: 2, read since v3.141.0) and import "lib/x.bot" (a bot in several files, read since v3.145.0). A cloud runner receives the main workflow as an AST but parses a subbot child, and a fragment, as text with its own binary, so a build older than the release fails at that parse — after admission, on a pod. iterion validate says so (C252), and a push refuses the bundle without the floor (409, --force overrides).

Four surfaces honour it, all through the same predicate (bundle.CheckManifestEngine):

SurfaceBehaviour
iterion validateC250 (error) when unmet, C251 (warning) when this build carries no orderable version
iterion remote admin bots push (and any bot-source write)409 naming the floor and where it came from; --force pushes anyway and the response carries the overridden requirement as a warning
iterion run / resume, studio, dispatcher, subbot childrenrefused before a worktree or sandbox is created; run ends failed with BOT_REQUIRES_NEWER_ENGINE
cloud runnerrefused before the first node; run ends failed (never failed_resumable) with BOT_REQUIRES_NEWER_ENGINE, the delivery is acked so no redelivery repeats the same arithmetic

The push guard's floor is the minimum of the server's own build and every runner build observed on runs in the last 7 days (Run.runner_version) — a queued run lands on whichever pod takes it, and the server is the half that compiles the bot.

Where the contract is deliberately NOT enforced. Installing or packing a bundle for an engine you do not have yet is legitimate — you install, then upgrade — so iterion bundle pack, iterion marketplace install and botinstall (a .botz from a URL or a local path) do not check it; the launch and iterion validate do. The cloud publisher does not check it either: during a rolling deploy the fleet is mixed, and the publisher's floor (a minimum across pods) would refuse a run that the pod actually taking it could serve. The runner decides against its OWN build, which is exact. What every path shares is the manifest decoder: a requires: block iterion cannot read refuses the bundle wherever it is opened.

Determinism

iterion bundle pack produces a reproducible archive:

  • entries sorted alphabetically;
  • every ZIP entry stamped with a fixed modtime (1980-01-01, zipEpoch);
  • modes normalised (0o644 for files, 0o755 for dirs);
  • compression pinned to zip.Deflate.
bash
iterion bundle pack my-bot -o a.botz
iterion bundle pack my-bot -o b.botz
sha256sum a.botz b.botz
# 03551558…  a.botz
# 03551558…  b.botz   ← identical

This matters because a container-independent SHA-256 over the sorted (path, file-bytes) pairs is the cache key the consumer side uses to look up the extraction slot at ~/.cache/iterion/bundles/<first-2>/<full-hash>/ (Windows truncates the slot name to 16 chars to stay under MAX_PATH). The digest ignores the container format, so a ZIP and a legacy tar.gz of the same files share a cache slot. Two machines packing the same source produce the same hash → cache hits become trivially shareable (e.g. via a CDN that serves the archive but lets each machine extract locally).

Resource resolution at run time

When iterion run my.botz (or a directory bundle) executes:

  1. Skills in skills/ are copied into <workDir>/.claude/skills/ with marker-aware collision handling (<workDir>/.claude/skills/.iterion-managed/<name>.sha256 records the hash of each file we last mirrored):

    • File doesn't exist → copy, record marker.
    • File exists & content matches source → no-op (already current).
    • File exists & content matches marker → refresh from source (we wrote it last, user hasn't customised — fixes the v0.1.0-shadows-bundle-upgrade trap).
    • File exists & content matches neither → SHADOW with a warning (genuine user customisation OR a different bundle owns the name; "workspace wins" contract preserved).

    Bundle skills mirror first, so on a name collision a bundle skill wins over a plugin skill and a skill-library skill (precedence: bundle > plugin > library > hand-authored — ADR-059).

  2. Prompts in prompts/*.md are merged into the AST prompts: table before static validation runs, so node-level system:/user: references against bundle filenames type-check. Workflow-declared prompts always win on collision.

  3. Attachments listed in manifest.yaml's attachments: map are promoted via store.WriteAttachment before the host's attachment-promote callback, so a runtime upload of the same name overrides the bundle default.

  4. Sandbox: when active, the bundle directory is bind-mounted read-only at /run/iterion/bundle (parallel to /run/iterion/attachments). Resources stay reachable from inside the container even though the cache slot lives outside the workspace mount.

Cache & resume

Bundles are extracted once, content-addressed by hash. The slot is marked ready with a .ready sentinel for atomic concurrent extraction and carries a bundle.lock recording the full hash + original archive path.

  • Cache hit: iterion run my.botz reuses ~/.cache/iterion/bundles/<hash>/ immediately.
  • Cache miss / GC: iterion re-extracts from BundlePath recorded on the run.
  • Cache + source both gone: resume fails with a clear hint pointing at the archive to re-supply.

iterion resume --run-id <id> re-opens the bundle from the run's persisted BundlePath automatically — the user doesn't re-type --preset or paths, the engine pulls them from run.json.

CLI reference

iterion bots create <slug>               Scaffold a bundle source layout.
iterion bots sync                         Materialize project `bots.lock` pins.
iterion bots update <name> [--ref <ref>]  Advance one pin and materialize it.
iterion bundle pack <dir> [-o file]      Build a deterministic .botz from a dir.
                       [--force]         Overwrite the output if it exists.
iterion validate <bundle.botz|dir>       Validate a bundle and its workflow.
iterion run <bundle.botz> [--preset]     Run a workflow from a bundle.
iterion resume --run-id <id>             Resume a bundle-launched run.

Files the packer skips

The packer ignores patterns that are never useful inside a bundle and that would defeat determinism:

  • .git/ — version control noise.
  • .iterion/ — local run store of past iterion runs.
  • __pycache__/, *.pyc — interpreter caches, including those generated by executing a materialized bundle tool.
  • *.botz — prior builds (avoids accidental nested packaging).
  • .DS_Store, *.swp, *~ — OS/editor scratch.

Symlinks, devices, sockets, and other non-regular entries are rejected at pack time with a clear error.

Troubleshooting

bundle: re-extract <path> required (cache miss; original archive absent) The cache slot was purged and BundlePath no longer resolves on disk. Re-supply the archive (or rebuild from source with iterion bundle pack).

bundle: schema_version N not supported by this iterion build The bundle was produced by a newer iterion. Either upgrade your iterion install or downgrade the bundle (set schema_version: 1 in manifest.yaml).

bundle skill "X" shadowed by existing workspace entry A skill with the same name already exists at <workDir>/.claude/skills/. The workspace copy wins — rename either to disambiguate.

bundle/pack: symlinks not allowed The packer refuses symlinks to keep the archive content-stable. Move the target into the bundle tree (or copy it explicitly), or use the filesystem outside the bundle if it's a host-specific resource.