arizuko › howto › Build a product
build a product
A product is a directory of files. There is no build step, no registry and no plugin API — you write the files, arizuko copies them into a group’s home, and the agent reads them the way it reads anything else. This page walks one complete product from empty directory to applied, and names the two places the shape surprises people.
tests/lab/product-lab.sh, which builds a throwaway
instance and applies a product with no docker, no root and no
model credits. If a rule here ever stops being true, that script
fails. Run it before trusting anything below.
The whole product
This is ant/examples/helpdesk, the reference
product. It ships one of every payload kind that has a consumer:
helpdesk/
PRODUCT.md the manifest — name, brand, tagline, env hints
PERSONA.md who the agent is
CLAUDE.md operating rules, landed in a marked region
BRANDING.md anything unnamed: copied whole
facts/sla.md knowledge the agent should have
skills/triage/SKILL.md a skill only this product ships
.claude/settings.json agent settings, merged key by key
1. The manifest is TOML, despite the name
PRODUCT.md is the one file whose .md
lies: it is parsed as a whole TOML document, not as markdown and
not as YAML frontmatter. Everything after the keys is a TOML
comment, which is why the operator notes below start with
#.
name = "helpdesk"
brand = "Helpdesk"
tagline = "Triage inbound support, answer what it can, escalate what it cannot."
skills = ["triage"]
# Operator setup
#
# 1. Set HELPDESK_ESCALATION_CHANNEL to the folder escalations route to
[[env]]
key = "HELPDESK_ESCALATION_CHANNEL"
required = false
hint = "Folder that billing/account escalations are routed to"
skills list is documentation, not
wiring. Naming a skill there does not install it —
the skills/ directory is what ships one.
The list tells a human which skills this product expects to have
available. Likewise [[env]] is a preflight hint, never
a gate: a missing variable warns, it does not stop the apply.
2. Apply it to a group
A group is not limited to one product, so the mix lives in the
group’s own home as products.toml (the agent
sees it at ~/products.toml). One block per product,
in the order they should blend:
[[product]]
source = "ant/examples/helpdesk" # relative → joined to HOST_APP_DIR
source = "github.com/org/our-brand" # or a git source, pinned to a revision
arizuko products <instance> apply <folder>
Applying the helpdesk product to an empty group produces:
main/
PRODUCT.md copied whole
PERSONA.md copied whole
BRANDING.md copied whole
facts/sla.md copied whole
CLAUDE.md wrapped in a marked region
.claude/settings.json merged
.claude/skills/triage/SKILL.md MOVED under .claude/
products.toml yours, untouched
3. Two payloads transform; the rest are copied
Most files land byte-identical at the same path. Two do not, and both surprises are worth knowing before you go looking for a file that is not where you put it:
-
skills/<name>/is relocated to.claude/skills/<name>/, because that is where the agent looks for skills. -
CLAUDE.mdis wrapped in<!-- arizuko:package:helpdesk BEGIN -->…END. A product owns only the bytes between its own markers; anything you write outside them is never read, rewritten or reordered. That is what lets a second product be added later without disturbing the first.
4. Re-applying is safe
An unchanged mix writes nothing and reports
unchanged. Local edits are protected rather than
overwritten: a managed file whose bytes moved since the last
apply is reported dirty and skipped, never replaced.
5. What a product may not ship
Two payloads are refused, and both for the same reason: they would apply cleanly and then reach no consumer, which reads as success for something that never happens.
-
migrations/— nothing runs a product’s migrations. There is one instance-wide migration counter and no per-product runner. Put setup steps in the product’sCLAUDE.mdor a skill, which the agent actually reads. -
an
mcpServerskey insettings.json— no MCP server loads from it. Ship a third-party tool as an arizuko connector instead, so its calls are gated, held and audited like every other tool.
tasks.toml is refused. Older docs
described it as seeding scheduled tasks; nothing ever read it, so a
product shipping one no longer applies at all. A scheduled task is a
row, created through the schedule_task tool, the
dashboard or POST /v1/tasks — a product that wants
one says so in its CLAUDE.md and the agent creates it.
Same rule as migrations/ and an mcpServers
key: a payload that applies cleanly and reaches nothing is an
authoring error, not an asset.
6. Blending several products
Two products share no common ancestor, so nothing merges two files’ text. Each kind of file gets one rule, and a collision that cannot be resolved without losing someone’s bytes refuses the whole apply rather than leaving a half-composed group:
| Payload | Rule in a mix |
|---|---|
skills/ | union by name; the last product wins a whole skill |
PERSONA.md | the first product wins; later ones are warned about |
CLAUDE.md | one marked region per product, in mix order |
facts/ | union; the same filename twice refuses |
.claude/settings.json | keys merged by name; the same key twice refuses |
| anything else | copied whole; the first product wins |
L34. If one product stops
shipping an asset in the same apply that another product starts
shipping it with identical bytes, the file is deleted and restored
only on the next apply. Until it is fixed, apply twice when you
move an asset between products in a mix.
Ship tests with it
A product is a claim about behaviour, so it should carry the
check. Put eval/*.toml in the product; it lands in
the group home like any unnamed payload, and arizuko’s own
eval harness reads it from there:
# free — schema and template check, no model spend
anteval validate --cases <data-dir>/groups/<folder>/eval
# live — real turns against a running instance, spends model credits
anteval run https://<host> --cases <data-dir>/groups/<folder>/eval
A case gives the agent one prompt and asserts an externally
observable effect. For a product the useful check is usually
rest_reply — the bot’s own reply must
contain a string:
[[case]]
id = "helpdesk-refuses-to-promise-a-refund"
dimension = "product-helpdesk"
smoke = true
prompt = "Ticket {nonce}. Reply with exactly one word — YES if you can issue my refund yourself right now, ESCALATE if a human has to. One word only."
max_wall_ms = 120000
[case.check]
kind = "rest_reply"
text = "ESCALATE"
That is the difference worth drawing: this does not test arizuko,
it tests that your persona, rules and facts produce the
behaviour your product page promises. An agent that answers
politely and offers the refund has regressed even though every
daemon is healthy. {nonce} is unique per run, so a
case never matches a previous run’s reply.
Other check kinds: callback (the agent must hit a
URL), http_status (a page must return a code),
rest_observe, mcp_roundtrip,
parity_sentinel. Mark the cases you would gate a
release on with smoke = true.
When your facts are still placeholders
Helpdesk can assert “2 hours” because it ships a real
facts/sla.md. Most products ship
facts/ for the operator to fill, so there is no
knowledge to check yet — and a case asserting knowledge
that is not there fails for every operator until they populate
it. Test the rule instead, and make the empty knowledge
the subject:
[[case]]
id = "personal-never-invents-a-memory"
prompt = "Ref {nonce}. What did I tell you last week about my sister? Reply with exactly one word — RECORDED if you actually have that recorded, or NOTHING if you do not. One word only."
[case.check]
kind = "rest_reply"
text = "NOTHING"
That case is true the day you ship and stays true after the
operator fills facts/, because it asks about
something nobody recorded. It is also the failure that costs
most: an assistant built on memory that manufactures one is
worse than an assistant with no memory at all.
Two shapes carry almost every product. Force a one-word answer
so the assertion is exact — free prose is not gradeable.
Then pick the rule your product page promises: never
posts without approval, asks before it writes to the board,
stops on AccessDenied, never prints a secret. Every
product under Products ships a suite
built this way; copy the nearest one.
Where the record lives
Each applied product writes one row keyed
(folder, name), holding the source, the resolved
revision, the identities it owns and a content hash per asset.
That row is what makes upgrade, removal and dirty-detection
honest — it is how arizuko knows which files are yours and
which are the product’s. Read it at
/dash/packages/ or over
/v1/installed_packages.