Skip to content

Authoring a candy

The quickstart reads a box that already exists. This page writes one.

A charly project is a charly.yml plus two discovery directories:

my-project/
├── charly.yml # the project entry point — deploys live here
├── candy/
│ └── my-tool/
│ └── charly.yml # a LAYER candy — one concern
└── box/
└── my-shell/
└── charly.yml # a BOX — a candy carrying base:

Scaffold it:

Terminal window
charly box new project my-project

Every command below names that project with -C, so none of them depends on which directory you are standing in.

charly.yml is the only filename charly knows. Everything else — which files to import:, which directories to discover: — is configured there.

Terminal window
charly -C my-project box new candy my-tool
charly -C my-project candy add-rpm my-tool ripgrep

The editor verbs go through the YAML node API, so comments and key order survive every edit — which is what makes them safe for an agent to drive. You can equally hand-edit the file.

Three fields are mandatory on every candy, and the gate enforces all three: a CalVer version:, a non-empty description:, and a plan: carrying at least one deterministic check: step. That is not ceremony — it is what makes the catalog and the spec is the test true rather than aspirational.

A real one — ripgrep — carries exactly that shape: a CalVer version:, a description: written for a stranger, a package: list, and a plan: of deterministic checks (the binary lands at /usr/bin/rg, rg --version parses, a present pattern matches, an absent one exits non-zero).

Write the description: for a stranger — it is published verbatim as that candy’s card wherever the candy’s project is published, and it is baked into every image that composes the candy.

Package names differ across distros, so declare them under distro: and let the resolver cascade most-specific-first:

package: # the base — installed on every distro
- git
distro:
fedora:
package: [ripgrep]
arch:
package: [ripgrep]
"debian,ubuntu": # compound — shared by both
package: [ripgrep]

Python belongs in pixi.toml, npm in package.json, Rust in Cargo.toml — drop the manifest in the candy directory and the builder stage is detected automatically. Do not reach for command: pip install.

Terminal window
charly -C my-project box new box my-shell --base fedora --candy my-tool

Or write it directly — a box is the same candy: keyword plus a base:. A fresh project has no local fedora box, so base: fedora resolves to the external image and carries no distro tags; declare distro: explicitly, or no package-install RUN is emitted and check box fails:

my-shell:
candy:
description: A minimal dev shell with my tool.
base: fedora
distro: ["fedora:43", fedora]
candy:
- my-tool
Terminal window
charly -C my-project box validate # the gate — silence is the pass
charly -C my-project box build my-shell
charly -C my-project check box my-shell # runs the baked plan in a disposable container

To prove the deployed behaviour too, declare a disposable bed in charly.yml and run it:

check-my-shell:
pod:
image: my-shell
disposable: true
description: Disposable bed for my-shell.
Terminal window
charly -C my-project check run check-my-shell

disposable: true is what authorizes charly to destroy and rebuild the deployment unattended. It is never inferred — see disposability is the license.

Prefer the declarative verbs over command:. mkdir:, copy:, write:, link:, download: and setcap: all exist; command: is the escape hatch. In particular write: takes inline content: and stages it as a file, so you never need a shell heredoc.

Never split a service into -host and -pod sibling candies. A candy that needs the same service under both supervisord and systemd declares both forms in one service: list, and the init system at deploy time picks. sshd is the canonical example, and it is one of the candies in the box the quickstart reads.