-
Notifications
You must be signed in to change notification settings - Fork 0
Packs
Packs make a conductor configuration distributable. A pack is a
self-contained, versioned bundle of behavior — workflows, named steps,
fleets, policy, checks, and (disarmed) triggers — that anyone can install from a source, parameterize,
override, and compose. The model is Terraform-modules-for-conductor: a
top-level packs: block where each entry is a sourced, versioned, parameterized
instance of a pack, namespaced under its instance name.
The design goal in one line: turnkey to install, impossible to auto-arm, cleanly updatable.
Status: this page documents the pack mechanism shipped so far — the
packs:block, the manifest, resolve/lockfile, namespacing + binding, settings, disarmed triggers, dependencies, and theinit/packCLI. See Limitations for what is not yet covered.
A pack entry carries a use: naming its origin. There are three shapes, and
the first one is how you name a conductor-blessed pack:
packs:
pr-review-team:
use: conductor-packs/pr-review-team # the OFFICIAL registry
kit:
use: acme/conductor-packs/kit@^1.2 # a third-party repo
house-style:
use: ./packs/house-style # a local folderconductor-packs/ is a reserved namespace, not an org: it resolves to
github.com/NodeSpy/conductor-packs, the official pack registry, which is
trusted by default. Writing it costs one segment
and buys the thing that matters — you can see, at the reference site, that this
pack is fetched from a conductor-operated repo rather than being some local
name. Everything after the namespace is the pack's path in that repo, and an
@constraint suffix works as it does everywhere else.
use: otherwise follows the same resolution as a connector's or runtime's —
see Plugins — with two differences, both because packs are config rather
than binaries:
-
there are no builtin packs, so a pack reference never resolves in-binary;
-
and therefore a bare pack name is an error.
use: pr-review-teamcould only ever have meant the official registry, which made a blessed-registry fetch and an arbitrary string look identical. The loader rejects it and names both routes:use: "pr-review-team": a bare pack name is ambiguous — write "conductor-packs/pr-review-team" for the official registry, or "owner/repo/pr-review-team" for a third-party packThe same error, keyed to the entry name, comes back for a
packs:entry with nouse:line at all — the key used to imply a bare name, and no longer does.
The reservation has one cost, deliberately. If your third-party pack really does live under a GitHub org literally named
conductor-packs, write the host out:use: github.com/conductor-packs/<repo>/<name>. That is an ordinary host-qualified reference and is unambiguous.
use: is required only at the top level. A dependency's source comes
from its parent's requires.packs.<alias>.source; the dependency's own use:
is consulted only if that is absent.
source: (below) is the older, longer spelling. It still works and still wins
when both are set, because it can express go-getter forms use: cannot
(git::ssh://…).
packs:
review: # instance name == the namespace
source: github.com/your-org/packs//review-kit@v1.0.0 # @ref PINS (tag/branch/sha)
version: 1.0.0 # metadata only, NOT a pin — recorded in the
# lockfile; see Lockfile and reproducibility
auth: house/gh-pat # OPTIONAL fetch credential for a private source,
# resolved through your secrets:/vaults:
preset: claude # pick a settings preset
settings: { heavy_model: claude-opus } # or override individual settings
connectors: { github: gh } # BIND the pack's required github -> your gh
secrets: { review_token: house/review } # BIND a required secret -> your vault ref
policy: { budget: { max_cost_usd: 5 } } # deep-merges onto the pack's bundled policy
steps: # OVERRIDE a pack step, by reference
review-flow/review: { model: my-fleet }
handoff: { workspace: local } # OVERRIDE the bundled step (deep-merge)
models:
reviewer: claude-opus-5 # OVERRIDE a bundled fleet
decide: { observe: review_log, escalate: false } # the pack's decide: steps — see Decide-Steps
on:
github.pull_request: { filter: { not_label_any: [wip] } } # add a filter to a trigger, by name
triggers:
"*": { enabled: true, repos: [your-org/app] } # arm EVERY shipped trigger, one consent
on_review_request: # the pack ships this DISARMED
enabled: true # you arm it
repos: [your-org/app] # required for a github trigger — this IS the consentA pack that ships six triggers should not make you paste the same repo list
six times. The "*" key supplies arming defaults (enabled, repos,
filter, policy, gate) to every trigger the pack ships; a named key
refines that one (its repos:/filter: REPLACE the wildcard's, so you can
narrow as well as widen).
packs:
review:
triggers:
"*": { enabled: true, repos: [your-org/app] } # all of them, here
deploy: { repos: [your-org/infra] } # …except this one
nightly: { enabled: false } # …and not this one at allNamed wins field by field, and anything it leaves unset keeps the wildcard's
value. repos: REPLACES rather than appends — repos are the consent, so an
operator must be able to narrow what "*" granted, not only widen it.
"*" supplies consent; it does not bypass it. An armed trigger with no repo
list is still refused.
A triggers.<name> value may be an object (one instance) or an array
(N instances of the SAME trigger, each with its own arming). One pack trigger,
armed for two teams with different gates, without the pack author shipping two
near-identical triggers or you forking the pack:
packs:
review:
triggers:
review: { repos: [team/app] } # object -> ONE instance
deploy: # array -> N instances
- { repos: [team-a/*], gate: { run: [review/strict] } }
- { repos: [team-b/*], filter: { label_any: [urgent] } }Each instance keeps its own dedup, session and outcome state, so the two armings never suppress each other on an event they both match. The object form is one instance and changes nothing about a single-instance config.
There is no instance id. An instance is just an entry in the array you wrote — edit it where it sits. Conductor derives the per-instance state key from the entry's CONTENT, which means reordering the array changes nothing: each arming keeps its own history because that history was never tied to its position. Editing an entry, on the other hand, makes it a different arming with its own state — which is the same rule read the other way.
Two byte-identical entries are one arming written twice, and a load error.
Consent is per instance. An armed entry with no repo list is refused on its own account — giving one entry repos does not let another through.
An on: overlay addresses the trigger by name and applies to all of its
instances.
A floating version range picks up new triggers. With
version: "^1.2", a trigger added in a later pack release is armed on your consented repos at the nextinit/update — that is what "every trigger the pack ships" means, and the lockfile diff is where you see it. Pin exactly (version: "1.2.3") to freeze the set. Conductor does not enforce a pin for"*"today; read the lockfile diff on update.
The block is the override surface — no separate drop-in files. policy:
deep-merges in order bundled pack policy <- instance policy: <- a trigger
arm's own policy: (most specific wins).
This single rule decides what a pack may ship vs must bind:
-
Define-in-pack (shipped, namespaced, overridable):
workflows:,models:(fleets),workflows:,policy:,checks:, and its disarmedtriggers:. Pure behavior. -
Bind-only (declared in
requires:, wired in the block, never shipped):connectors:,secrets:,vaults:,stores:,runtimes:,hosts:,handoffs:. Anything carrying credentials, endpoints, or infra identity.
This is the security boundary. A pack fetched from a stranger can define prompts, policy, and workflows, but it cannot smuggle in a credential or point at infrastructure. A pack that ships any bind-only section is rejected at install.
Everything a pack defines is auto-scoped under the instance name:
steps.handoff → review/handoff, workflows.review-flow →
review/review-flow. Two packs can both define reviewer and never collide.
Refs inside the pack are written bare and resolve pack-local — the author
writes no prefixes. The loader scopes them. The one boundary that reaches global
names is requires:: a required connector/store/secret/handoff
bound to a global, resolves in the consumer namespace. You reference a pack's
entry point qualified: call: review/review-flow.
Every resource a pack declares is satisfied one of three ways, by the value's shape:
| shape | meaning |
|---|---|
| absent | the pack's bundled default |
| map | override: keep the bundle, deep-merge changes onto it |
A step is addressed by REFERENCE — <workflow>/<step-id>, or
<workflow>[<n>] for a step with no id: — in the pack's own
(un-namespaced) vocabulary:
steps:
review-flow/review: { model: my-fleet }
review-flow/post: { workspace: local }
# (omit a step entirely to keep the bundled default)The scalar bind form is gone. It named a top-level
steps:entry to swap in wholesale, and there is no such section any more. Reach your own config with a YAML anchor instead —review-flow/review: { <<: *my-reviewer }— which composes with the pack's own fields rather than replacing the whole step.
Override deep-merges with replace semantics: nested maps merge recursively,
but scalars and list fields are replaced, not appended. So an override of a
bundled step's skill.verbs fully replaces the bundled list — you can narrow
a bundled step's capabilities, not only widen them. (This differs from
imports:, where lists concatenate; a pack override is a deliberate restriction
surface.)
A pack's own steps share configuration the same way the main config does —
a YAML anchor under a top-level x- key, merged with <<:. Anchors are
file-local, so a pack's house style stays inside the pack and cannot be
reached (or clobbered) by the consumer:
x-templates:
house: &house { type: agent, archive_when_done: true }
workflows:
review-flow:
steps:
- <<: *house
id: review
workspace: worktree
guidance: "Review only what the diff changes."
prompt: "Review {{.repo}}#{{.pr}}."A pack's overridable surface is its workflow steps, addressed by
reference. Give each an id: — that is what a consumer writes, and it is
also the step's identity slot, so a rename moves both together. A step
with no id: is still addressable by index (review-flow[1]), but that
shifts when you insert a step above it, which is a poor thing to ask of
your consumers.
A pack manifest declares the resources it needs:
requires:
conductor: ">=0.8" # daemon-version compat (the fleet auto-updates)
connectors: # sockets AND the capability boundary
github: "*" # any version
jira: ">=2.0" # a plugin connector at a compatible release
# connectors: [github] # sugar for { github: "*" }
stores: [cache]
secrets:
review_token: { desc: "token the review-poster uses" }
packs:
base: { source: github.com/your-org/base-kit, version: "^2.0" }conductor init checks each socket is satisfied.
It is not only a list of things to bind. A pack's skill.verbs may name no
connector outside it, and a wildcard inside a pack means "all verbs of my
required connectors":
requires: { connectors: { github: "*" } }
workflows:
review-flow:
steps:
- id: review
skill: { verbs: ["*"] } # => github.* only
# skill: { verbs: [github.*] } # fine — declared
# skill: { verbs: [pagerduty.*] } # LINT ERROR — not declaredEnforced twice: conductor pack lint errors on a pattern naming an
undeclared connector (so the author sees it while authoring), and instantiate
intersects the grant as a belt (so a hand-authored pack that never ran
lint still cannot exceed its interface, with anything dropped surfaced as a
load notice). This is what makes a pack from a stranger safe to install: it
can only ever hand an agent the connectors it declared — never quietly scope
onto your pagerduty or your secrets connector.
Each constraint is checked at instantiate against the connector's resolved version — the installed release for a plugin connector, the daemon version for a builtin. It gates, it does not fetch (connectors are bind-only), and a mismatch is a clear load error naming pack + connector + required-vs-actual. An unknown version (a dev build, a plugin not yet installed) warns and skips the gate rather than failing the box.
There is no
requires.roles. It was vestigial onceagents:was removed: "which model fills this role" is answered by Model-Selection and the mirrored overlay, and "what must a binding be able to do" is answered byrequires.connectors, which bounds every grant the pack can make.
A pack ships typed settings: with defaults, plus named presets: (pre-filled
setting bundles). The consumer picks a preset and/or overrides individual
settings — without editing the pack. Settings are substituted into the pack's
templated fields at instantiate time with ${settings.NAME}.
${settings.NAME}is substituted as text, so it must sit in a string-valued field (a prompt, an option, guidance) — not a numeric field like a gate'smax_revisions.
A pack step that omits model: falls through to your default runtime, and
one that names a FLEET resolves against whatever models you actually have — so
a well-made pack runs with near-nothing bound. See Model-Selection.
A pack can bundle triggers from several sources (github, gitlab, pagerduty).
repos: is meaningless to a pagerduty trigger, so scope is not a
pack-level field: each trigger binds to your connector of its own source
type, and the scope lives there, configured once.
connectors:
github: { repos: [me/app, me/api] } # scopes the github-sourced triggers
pagerduty: { service: PROD } # scopes the pagerduty one
packs:
incident-responder: {} # each trigger finds its own connector-
More than one connector of a type is ambiguous, and conductor says so
rather than guessing: disambiguate with
packs.<name>.connectors: { github: work-github }. -
No connector of a type leaves those triggers dormant, surfaced as a
load notice. The rest of the pack runs — a consumer without pagerduty still
gets the github half. A pack author whose pack is meaningless without a
source marks it
requires.sources.<type>.requiredand gets a hard error instead.
requires.connectorsis different, and stricter. The dormancy above is for a source a pack merely uses. A connector the pack declares is required by default: leaving it unbound is a load error, because a pack that installs clean and then does nothing when the event arrives is worse than one that says what is missing. An author whose pack genuinely degrades opts in per connector:requires: connectors: github: "*" # required (the default) pagerduty: { version: "*", required: false } # optional — dormant if unboundAn unbound optional connector is a load notice, and the parts of the pack that use it go dormant.
You do not write a binding that carries no decision. For each
requires.connectors entry you did not bind explicitly:
| your config has | what happens |
|---|---|
| exactly one connector of that type | bound automatically |
| two or more | a load error naming them — which one is a real choice, and yours |
| none | unchanged: required → load error, required: false → dormant |
Matching is by the instance's use: type, not its name, so a connector called
gh is found for a pack that requires github. An explicit
packs.<name>.connectors: binding always wins. A sole candidate that does not
satisfy the pack's version constraint is an error, never a silent bind.
This is plumbing, not consent. A pack that can now reach your github connector still fires on no repo until you arm its triggers and name them — the repo list is the consent, and auto-binding never supplies it.
packs.<name>: mirrors the pack's own sections, and keys deep-merge onto
its members by name — no pack-specific override language:
packs:
pr-review-team:
use: conductor-packs/pr-review-team
on: # its triggers, by name
github.pull_request: { filter: { not_label_any: [wip] } } # ADD a filter
gitlab.merge_request: { enabled: false } # turn one off
steps: # its steps, by reference
github.pull_request/sec: { guidance: "focus on authz + SSRF" } # ADDITIVE
review-flow/summarize: { enabled: false }
models: # its fleets, by name
reviewer: claude-opus-5
connectors: { github: work-github } # instance disambiguationguidance: is appended rather than replaced, enabled: false turns a trigger
or step off, and the consumer wins on conflict. Only named members are
addressable, so a pack must name what it wants you to be able to reach — and
an overlay key that matches nothing is an error, not silent dead config.
Everything a pack ships except triggers is passive: a workflow runs only when a
trigger fires it or you conductor run it. So triggers are the only active
surface, and that is the only place consent lives.
- The pack ships its triggers (you never rebuild the event mapping / gates / steps), but they arrive disarmed — disabled, with no repos.
-
Arming = the two environment-only things:
enabled: trueand, for a github-sourced trigger,repos:. The repo list is the consent: arming a github pack trigger withenabled: truebut norepos:is a hard error at load, not a silent no-op — the github matcher treats an empty repo set as "match every repo", so an unscoped arm would otherwise run the pack on every repo the consumer's connector can reach. A non-repo-scoped source (manual,rss, …) has no repo concept and is exempt from this requirement.
Guarantee: a freshly-added pack does nothing until you arm a trigger, and a github trigger cannot be armed at all without explicitly scoping its repos.
-
Workflows are addressable by qualified name (
review/review-flow) — call them from your own triggers or nest them in your own workflows. -
A pack calls only its OWN workflows. Every workflow reference a pack authors is namespaced to the pack at install, so a bare name inside a pack resolves inside that pack — never against your config, and never against another pack's. This holds for every spelling of a call: the step form (
call: review-flow), the verb form (uses: workflow.run, options: {name: review-flow}),workflow.save, and any of those nested in acompensate:, aparallel:branch, or a stephooks:entry. It holds for a TEMPLATED name too —{{ .pick }}is namespaced before it renders, so a runtime-chosen workflow still lands inside the pack.Naming a workflow the pack does not ship is a load error, like an undeclared connector. A DECLARED dependency counts as its own:
requires.packs: {base: …}installs at<ns>/base, sobase/fetchis the submodule call you read in the manifest.This is what makes
requires.connectorshold. Without it a pack could shipuses: workflow.run, options: {name: review-flow}and run YOURreview-flow— every connector inside it — while declaring none of them. -
Pack dependencies (
requires.packs) are satisfied by the same recursivepacks:instance block. Behavior can be overridden at any depth; environment can only be forwarded down — the concrete binding to a real credential/repo happens only at the top, with the consumer. -
Guards mirror the workflow engine: a depth cap (
MaxPackDepth), cycle detection (reports the chain), and a total-count backstop.
conductor init writes conductor.lock.yaml next to your config — the whole
resolved graph, each node pinned by a resolved revision and a tree digest. Commit
it: conductor init on another machine yields a byte-identical setup, and a
changed remote is tamper-evident on the next init.
The instance block's version: is a semver constraint (Terraform/gems
style: ">= 1.2, < 2.0", "~> 1.1", "^1.0", "1.0"). An unpinned git source
resolves to the highest tag that satisfies it — a monorepo tags its
components "<subdir>/vX.Y.Z" so one repo can version many packs. Precedence: a
hard @<tag|branch|sha> on source: wins over any constraint; a constraint
selects a tag; a bare source with no constraint tracks the default branch. The
lockfile's resolved: sha is what actually reproduces the fetch, and re-running
conductor init re-resolves within the constraint.
conductor init [--allow-unlisted] # fetch the packs: block, write the lockfile, preview the effect
conductor pack list # configured instances + lock status
conductor pack plan # preview what the packs add (agents, skill grants, armed triggers)
conductor pack add <source> # fetch a pack, show its install review + a ready-to-paste block
conductor pack lint <pack-dir> # validate a pack is well-formed (author tooling)
conductor pack show <pack-dir> # render a pack's docs: settings, requires, exports, example
conductor pack remove <instance> # clear a pack's vendored tree + lockfile entries (alias: rm)
conductor pack update [--allow-unlisted] # re-resolve the packs: block and diff the lockfile
conductor update --packs # alias for `conductor pack update`
conductor pack add and remove do not edit your config: add prints a
block for you to paste (so you review the binds first), and remove clears the
vendored tree + lockfile and tells you which packs: block to delete.
Typical flow: edit packs: → conductor init → conductor pack plan → arm a
trigger → conductor validate.
A pack is a directory with a conductor-pack.yaml manifest. See
examples/packs/review-kit
for the reference. The manifest carries identity/discovery/compat metadata
(pack:), typed settings: + presets:, the public exports: surface, and the
bundled behavior. Run conductor pack lint before publishing.
An optional operator-level allowlist gates where packs may come from — the trust surface the lockfile can't provide (the lockfile proves unchanged, not trusted):
pack_trust:
allow:
- your-org/* # any repo under your-org (github.com implied)
- acme/review-kit # one specific repo
- gitlab.com/team/* # a written host is used as-isWith pack_trust: set, conductor init refuses any remote pack source — at
any depth, including a dependency's — that matches no allow: glob. Local
sources (your own disk) are exempt. Override once with
conductor init --allow-unlisted.
The official registry is in the default allowlist. A
use: conductor-packs/<name> reference resolves to
github.com/NodeSpy/conductor-packs//<name>, which is trusted with or without a
pack_trust: block — so adding an allowlist for your own packs never silently
breaks the blessed ones you already reference. Note the allowlist matches
resolved sources, not use: spellings: a pattern is written
github.com/NodeSpy/conductor-packs, never conductor-packs/* (which, as a
pattern, would mean a github org of that name).
The host may be omitted. An entry with no host defaults to github.com/,
through the same rule use: and pack sources use — a first segment containing
a . is a hostname, anything else is an owner. Write gitlab.com/team/* to
mean gitlab. A bare * stays host-agnostic and means everywhere.
* does not cross a /. It matches any run of characters within one path
segment, the same rule as Go's path.Match. That is deliberate: an allowlist
entry is the operator saying this org, or this repo, and a * that spanned
the separator would quietly widen it to somebody else's org.
Two forms cover almost everything:
| Pattern | Matches | Does not match |
|---|---|---|
github.com/acme/review-kit (or acme/review-kit) |
that repo, plus //subdir and @ref of it |
…/review-kit-fork, …/review-kit2
|
github.com/acme/* (or acme/*) |
any repo under acme (and their //subdir@ref) |
github.com/acme-evil/anything, gitlab.com/acme/anything
|
Prefer those. A partial-name wildcard like github.com/acme/conductor-packs*
still works, but it is a wider grant than it looks: it also admits
conductor-packs2 and conductor-packs-old in the same org. That takes write
access under acme to exploit, so it is not a hole the way a cross-/ match
was — but if you mean one repo, name it, and if you mean the org, say acme/*.
- Packs ship no connectors and no secrets — bind-only, enforced at install.
-
Install review (
conductor init/conductor pack plan) surfaces what a pack can do: agents,skill:grants (loudly), the triggers it wants armed and on which repos, and the resolved dependency tree. - Lockfile pins revisions → tamper-evident updates.
- Inert until armed — triggers are the only active surface and ship disarmed.
- Strict-decode + the degraded-boot fail-safe apply, so a bad pack can't hard-crash the daemon.
The following are not yet implemented and are called out honestly:
-
Signature verification (cosign / build attestation, §21 Phase B) is not yet
implemented. The source allowlist (
pack_trust:, below) and lockfile digest verification (drift warns at load) are. -
Ref-rewriting covers agent/workflow/check refs, connector prefixes in
uses/on/hooks (scalar and list-form), thestore:selector, team roles,skill.verbs,skill.allow_secrets,session.end_on, and pack-local team-role step references — but not the free-form runtime env-access templates{{ vault … }},{{ secret … }}, and{{ kv … }}. Those are not rebound: they resolve the consumer's global vault/secret/store by name, so a pack can reach undeclared environment through them. The loader warns on every such template it finds in a pack's behavior (atconductor init/conductor pack plan), so the reach is never silent — bind the value throughrequires:or pass it via a setting/workflow input instead. (http:///git://plaintext pack sources are also refused — an unauthenticated fetch can't be safely sha-pinned.) - Pack policy is folded onto the pack's own triggers; a pack workflow called from your trigger does not carry the pack's policy.
-
Pack-scoped memory/state namespace (§24) is not yet implemented: a pack's
agents may opt into memory (behavior), but two packs share the same memory
namespace. (The manifest-level
memory:backend is bind-only and rejected — it selects a store/dir, which is environment.) - Registry / discovery search — packs are URL/path-addressable; there is no central index yet.
Setup
The model
- Connectors
- Workflows
- Reuse
- Settings-and-Templating
- Packs
- Verbs
- Code-Steps
- Stores
- Runtimes
- Model-Selection
- Model-Discovery
- Steps
- Decide-Steps
- Grouping
- Memory
- Binary-Data
- Agent-Skill
- Policy
- Gates
- Teams
- Outcomes
- Cost-Accounting
- Secrets
- Hosts
- Isolation
- Trust-and-Isolation
Connectors
Operations
- One-Shot
- Callable-Service
- Runs
- Hand-offs
- Notifications
- Migration
- Controllers (legacy name → Runtimes)