arizuko

arizukohowto › 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.

Every claim on this page is checked by 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"
The 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:

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.

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:

PayloadRule in a mix
skills/union by name; the last product wins a whole skill
PERSONA.mdthe first product wins; later ones are warned about
CLAUDE.mdone marked region per product, in mix order
facts/union; the same filename twice refuses
.claude/settings.jsonkeys merged by name; the same key twice refuses
anything elsecopied whole; the first product wins
Known bug, 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.