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.

Shared fragment dependencies ​

The same two-sided key shares a .bot FRAGMENT — the files an import "lib/x.bot" reaches — so two bots stop carrying the same declarations (ADR-145):

yaml
# producer bundle
exports:
  fragments:
    - id: forge-auth
      path: lib/forge-auth.bot
# consumer bundle
dependencies:
  fragments:
    - name: forge-auth     # the name this bundle's `import "lib/<name>.bot"` reaches
      from: app-dev        # the exact manifest name of the exporting bundle
      id: forge-auth       # the exported id; defaults to name

Resolution is a materialization at install, by copy, into the consumer's own lib/: the parser, the unit loader, E045 and the lib/ confinement learn nothing, and every surface that reads the unit keeps reading what it reads now. What that costs is written down rather than hidden:

  • bots sync resolves, a lone install refuses. The lockfile is walked alphabetically, so a consumer a-bot is installed before .botz/z-lib exists; bots sync therefore materializes every pinned bundle FIRST and resolves fragments in a second pass. iterion bots install of a single bundle that declares dependencies.fragments is refused, naming iterion bots sync — resolving there would make the verdict depend on an accident of sorting.

  • A dependency may not shadow. A name that collides with a fragment the bundle itself ships is refused, never overwritten: a silent overwrite would make import "lib/x.bot" mean one thing in the source bundle and another after an install.

  • A miss is named where the dependency exists — at the install, with the bundle, the fragment and every directory looked in. No new parse-time diagnostic: at parse time the file is simply there or absent.

  • The cloud authoring surface RESOLVES a declared fragment, it does not merely tolerate the miss (#2865, the authoring half of #2853). A bundle edited in the studio never carries what an install materialized, so the cloud editor's open, its per-file apply, its write-back, the bot-source write gate and the studio assistant's own gate resolve declared dependencies.fragments entries through the same team-scoped tiers and the same producer rules the launch freeze uses. Four properties make it safe to rely on:

    • Lazy, then whole. The load or the compile runs FIRST, and a tier is read only once it has actually missed a declared path: a bundle with nothing missing — including one pushed from a synced working tree, whose fragment and record travel with it — costs nothing. Once a miss does trigger it, EVERY declared entry is resolved, the freeze's own shape, or a shared fragment that imports a second shared fragment would be launchable and refused by the editor.
    • Team-scoped. The team is the bot's own editor path (botsource://<team>/<slug>/<rel>), and the caller must hold it, with the right the bot-source routes require of a reader of a team's bots. A caller that does not gets no resolution and no byte of the producer.
    • Read, never written. A resolved fragment is marked as the producer bundle's, rendered read-only in the Source view, and refused by name by every save: persisting it would make the consumer SHIP the lib/<name>.bot it declares a dependency on, which is the shadow the launch refuses.
    • Both planes read the launch's no-shadow verdict; only a write refuses on it. It runs over every declared entry, missing or not — a declared entry the bundle also ships is exactly the one a miss cannot report — so no WRITE gate stores a bundle the launch refuses. A READ opens anyway, and leaves that entry UNRESOLVED: the document is built on the author's own file, which stays editable, so a bundle already in that state reaches the screen where the manifest is fixed instead of being answered as another bundle's content.

    A declared dependency the tiers cannot serve is still refused, and the refusal names the manifest entry, the producer bundle and what the resolution of THAT entry found — the tiers searched when the producer is nowhere, what the producer answered when it resolved and cannot serve the id, and that this request read no tier at all when the caller holds no authoring right on the team. Never iterion bots sync, which is no way forward for a server with no working tree; never "publish the producer" about a producer nothing looked for; and never the server's own filesystem path. A tier that did not ANSWER is answered as itself (503, retry), never as "the bundle does not compile": one bad store minute must not retire a bundle that resolves. A miss that is NOT a declared dependency's keeps the generic refusal and its remedy: that import is the author's to fix.

  • The cloud plane resolves at the launch freeze, before the parse. A bundle that reaches the server never carries what an install materialized, so the launch resolves every declared fragment while it freezes the bot's collection — through the same team-scoped tiers (team → platform → baked catalog) a child workflow's authority comes from, injected at the same lib/<name>.bot, the moment a bundle joins the collection. That moment is the point: the freeze PARSES each workflow, so a fragment that arrived after the walk would arrive after the E046 that blames the consumer's own file. Every bundle of the collection is resolved, a child's declarations included. Nothing durable is added: the fragment travels inside the run's snapshot, whose digest answers "which version of the shared logic did this run execute" at a tier that has no bots.lock, and workflow_sources records its bytes as it records the rest of the unit. A producer that resolves in no tier stops the launch, naming the consumer, the fragment, the producer and the tiers searched — not iterion bots sync, which is the install's remedy and needs a working tree; a tier that errors is transient and the resume retries it. There is no fragment store on the server: the tree stays the only place a shared fragment's bytes come from.

  • The pin keeps its meaning. A materialized fragment is recorded in the consumer's .iterion-materialized-fragments note and left out of the bundle's content hash, which is what bots.lock pins: resolving a declared dependency is not drift, and a second bots sync is a no-op. The note is honoured only for a lib/*.bot path whose bytes still match the recorded digest — a fragment edited after the install is hashed back in, because the bundle on disk is then no longer what any producer published.

  • The note is the bundle's local-state record, and it holds the other file an install changes: the generated bot catalogue (skills/iterion-bot-catalog.md) that a catalogue owner vendored under .botz/ has rewritten with the consumer workspace's routing table. That path IS one the producer ships, so leaving it out would not keep the pin true: the bytes it replaced are kept under .iterion/kept/ — a directory no walker of a bundle's content counts — and the content hash is computed from them. A kept copy is honoured only while its own bytes match the recorded digest, so kept bytes that are not the producer's can make the hash move (drift a bots sync repairs), never make a modified install read as pristine.

Shared skill dependencies ​

The same two-sided key shares a SKILL document — what a node's skills: [...] list names — so a bot stops carrying a copy of a playbook another bundle already publishes (ADR-144):

yaml
# producer bundle
exports:
  skills:
    - id: verify-build
      path: skills/verify-build.md
# consumer bundle
dependencies:
  skills:
    - name: verify-build   # the name this bundle's `skills:` lists reference
      from: app-dev        # the exact manifest name of the exporting bundle
      id: verify-build     # the exported id; defaults to name

Resolution is the fragment path with one directory changed: bots sync materializes every pinned bundle first and resolves in a second pass, a lone iterion bots install of a consumer is refused and names bots sync, a miss is reported at the install with the bundle, the skill and every directory looked in, and the copy lands at skills/<name>.md — where a shipped one would have been, so the run-start mirror, the install's skill count and validate's C231–C234 lints read it without learning that a skill can be a dependency. The materialized document is recorded in the bundle's local state and left out of the content hash, like a materialized fragment. What is specific to skills:

  • A dependency may not shadow a shipped skill, nor an operator-attached name. The first is refused at the install, as it is for a fragment. The second is a catalogue rule (bots/catalog_skill_refs_test.go): a bot that deliberately references a skill the OPERATOR supplies — a platform playbook a repo-agnostic bot must not pin — may not turn that choice into a dependency, and the gate reddens on a declaration that does.
  • A shared document keeps its name. A skill is addressed by the name: in its frontmatter as much as by its file name, so resolving an export under a name the document contradicts is refused, naming both sides: rewriting anything INSIDE the document is the per-bundle token substitution ADR-144 leaves undecided.
  • skills/iterion-bot-catalog.md cannot be a dependency. That path is the generated catalogue's, owned by iterion bots regen-catalog with the producer's bytes kept aside; two writers reconciling one line would erase each other, so the name is refused in the manifest.
  • The declaration shrinks the inventory, nothing else does.internal/skillshare keeps declaring every copy the tree still carries, and a family loses a member only when a consumer adopts the dependency and deletes its copy — one reviewed change per bundle.
  • The cloud plane resolves at the launch freeze, and the snapshot is the pin. A bundle that reaches the server never carries what an install materialized — PackDir leaves the local-state record and its members out of a .botz, and a bundle authored in the studio never had them — so the launch resolves every declared skill while it freezes the bot's collection, through the same team-scoped tiers (team → platform → baked catalog) a child workflow's authority comes from, and injects the document at the same skills/<name>.md. Every bundle of the collection is resolved, a child's declarations included. Nothing durable is added: the document travels inside the run's snapshot, whose digest answers "which version of the shared logic did this run execute" at a tier that has no bots.lock. A producer that resolves in no tier stops the launch, naming both sides — never a silently short skills directory; a tier that errors is transient and the resume retries it. There is no skill store on the server: the tree stays the only place a shared skill's bytes come from.

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.

A main.bot beside a skills/ or presets/ directory is a bundle even without a manifest. Discovery and opening path/main.bot use the same marker verdict: a presets-only bundle exposes its sous-bots in the launch form, and run and validate resolve --preset from its files. A regular file named skills or presets is not a directory marker.

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). The sources counted are main.bot, the fragments it imports, the children it declares, and every other .bot at the bundle root — an entry of its own, launched by path or declared by another bot, that no edge from main.bot reaches. A source the walk cannot read — a child in a sibling bundle, a root entry or a child that is a link out of the collection — is named (C253) rather than taken for profile 1.

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:

Subbot children use the bundle returned by their compilation on all four launchers. Shared-workspace children temporarily borrow the managed resource trees and restore them on return; see Child bundle resources for collision, devbox and pause/resume rules.

  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).

    The same skills are ALSO written, verbatim, to <workDir>/.claude/iterion-skills/ — a copy the engine owns. That directory is removed and refilled from the bundle on every mirror pass (including for a run with no bundle), so it carries the bundle's bytes under only the names the bundle ships, and no collision policy applies to it. ${BUNDLE_SKILLS_DIR} expands to it, resolving to the in-container pathname when sandboxed.

    Everything the mirror writes is also recorded — destination path and content hash — in <workDir>/.claude/.iterion-managed/mirror-manifest.json. That manifest is how the engine's dirtiness probes (wip bank, commit-and-finalize, pristine-worktree reclaim) tell the mirror's own rewrite of a tracked .claude/** file from the run's edit of one: bytes still matching the recorded hash (or a file the mirror's own pruner removed) are the mirror's, never the run's work; anything else under a tracked .claude/** is work. The .iterion-managed/ bookkeeping itself is never work, tracked or not.

    Which one to read: an AGENT discovers skills under .claude/skills/, where the workspace-wins rule is what lets an operator customise one. A tool node that parses a machine-readable iterion: data block out of a skill reads ${BUNDLE_SKILLS_DIR} instead — the workspace is a checkout of the repository being worked on, so a file found under .claude/skills/ may be the bundle's or the repository's and nothing read back can tell the two apart. A name the bundle does not ship has no file in the owned copy, which is what makes "this name is not covered" observable to the reader.

    ${BUNDLE_SKILLS_DIR} is absolute or nothing: a reader joins a skill name onto it, and an empty or relative value would resolve against the node's own working directory — the checkout. A relative workspace is resolved against the process directory rather than refused; one the process cannot resolve at all fails the run. A bot declaring the var should constrain it, e.g. [matching: "^/.*$"]: the launch gate reads the EXPANDED value, so it refuses "" and some/dir while admitting the "${BUNDLE_SKILLS_DIR}" a studio form re-sends unmodified. The default "${BUNDLE_SKILLS_DIR}" itself names a directory the RUN answers, so no compile-time reading may judge it: it is C181 — the pattern is checked on no path for that default, and the warning says so. Before #1610 the compile-time check (C161) compared the literal default text and refused it under a bare ^/; the |[$][{]BUNDLE_SKILLS_DIR[}] branch the catalogue's bots still carry is that workaround, and is no longer needed.

    The reset removes the directory recursively, and .claude is a path the checkout supplies: the engine resolves it and refuses the run when it lands outside the workspace, rather than removing a directory the workspace only points at. A workspace REACHED through a symlink is untouched by that rule — both sides resolve to the same tree.

    A child running in its parent's workspace borrows the directory the way it borrows .claude/skills, commands, agents and settings.json: one list names all five. The child's scope saves the parent's copy before the child's mirror replaces it and restores it on every exit — success, failure, or an adoption that aborted — so the parent reads its own bundle's names and bytes once the child returns. Children sharing a workspace take that scope one at a time. In a shared copy-based sandbox (a child adopting its parent's live pod) the same list drives the reset in the pod, before the child's copy is written through: the write-through seam adds files and removes none, so a name only the parent's bundle ships would otherwise answer for the child. Both halves fail closed — a file of the owned copy that does not land aborts the adoption, because a reader finding no entry for a name reports it as not covered. A file outside that copy keeps the seam's ordinary behaviour: a skill the agent cannot read is a degraded run, not a dead one.

  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.
  • *.bot.yaml files — an author document, the YAML twin a .bot can be written as (dsl.md): a draft of the bot, never a member of the bundle. It is left out of the archive and of the content hash on both sides (pack and extraction), and is never extracted from an archive that carries one (packed before this rule, or by hand): a bundle hashes the same with or without its drafts. An archive's directory entries create nothing at extraction either — the extracted tree is its files' alone, the content the hash sees — so two archives that hash alike land the same tree in the content-addressed cache whichever is opened first. A directory named like a draft is a directory. iterion bundle pack says how many drafts it left out. A plugin's source tree is not a bundle: PackTree carries every file whatever its name, and its hash is that archive's own, not a bundle identity.

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).

runtime/bundle: <workDir>/.claude is a symlink to <target> — the run-start mirror would write through it … The repository keeps its agent assets elsewhere and links .claude to them — a legitimate monorepo pattern, but one a run cannot start against: the skills mirror would write through the link into the target, a path no tree-noise entry names (the dir-only **/.claude/ rule never matches a symlink), so the wip bank would stage the mirrored files as the run's own work. The run refuses at mirror time and names the link and its target (#1569). Remedy: replace the symlink with a real directory — move the shared assets into .claude/ itself, or copy them there — and commit that. Every site that writes under .claude holds the same rule: a pass the run depends on refuses, an ambient pass warns and skips it. The run fails with this message from the run-start bundle mirror, the library mirror when the .bot declares skills (and from the injected library mirror the cloud path uses, always), a child's resource scope and its restore — that one prefixed restore child resources (backup …) — and the snapshot, restore and reset a copy-based sandbox runs against its OWN .claude copy, those three prefixed shared child resources (backup …) or reset borrowed sandbox resources, and carrying what the in-sandbox script itself said: shared .claude root <path> is a symlink — the snapshot, restore and reset would remove and rewrite through it, outside the workspace. That sentence leads the message instead, the typed one naming <unreadable> behind it, when the sandbox had no readlink to read the target with — and an exit 4 that is not this guard at all leads with its own cause the same way. The same shape only warns and skips the pass in the plugin-contribution mirrors, the hooks merge, the tier-sidecar wipe, the orphan pruner, the library mirror with no skill declared, and the chatbox skill attach — there the run continues, the message delivered without its skill. In the sandbox the link can also be one the run's own agent planted; the refusal is then what kept a recursive delete inside the workspace.

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.