# Deploy wizard

A customer answers exactly three things. Everything else about an Agent Box is set by Rekan.Ai.

That is the whole design: the panel is not a configuration console. Toolsets, skills, capabilities, the forbidden-action list, the human-approval baseline, the model endpoint and model name, resources, image, LXD project and profile are Rekan.Ai engineering decisions, and a customer cannot get them wrong because they are never asked. What is tenant-specific is small and personal enough to be asked in three steps.

StepQuestionAnswer becomes
1Personapersona: in box.yaml → identity in the coworker's SOUL.md
2Private dataprivate_data: in box.yaml → data policy in SOUL.md and guardrails.data_classes
3Gatewaygateway: in box.yaml → platform, route and credential names; credential values go to box.env

# What Rekan.Ai hard-sets

GET /api/v1/setup/questions returns this as hard_set — read-only rows the panel renders so the customer can see what they are getting without being asked about it:

  • Model — endpoint (REKAN_MODEL_BASE_URL), model name (REKAN_MODEL_DEFAULT) and the API-key env var. A pack never pins these: a pack says what kind of coworker this is, not which hardware serves it. The key value is the deployment's too: it lives in the control plane's environment (REKAN_MODEL_API_KEY, or the variable named by REKAN_MODEL_API_KEY_ENV) and is injected into every box's box.env on the way through — POST /boxes and POST /setup/deploy both do it. A customer is never shown the field, and a caller that supplies its own value still wins.
  • Backup endpoint — the second endpoint a box answers from when the primary fails with a rate-limit, overload or connection error, set by the deployment (REKAN_MODEL_FALLBACK_BASE_URL, REKAN_MODEL_FALLBACK_DEFAULT, REKAN_MODEL_FALLBACK_PROVIDER; REKAN_MODEL_FALLBACK_ENABLED=false removes it). It is not a variant of the primary: a fallback is a different endpoint serving a different model, so box.yaml carries it as its own block and the guest writes it to Hermes' top-level fallback_providers chain — one entry of {provider, model, base_url, key_env} — rather than to model.* keys. fallback_model is the legacy key (Hermes still merges it, but hermes fallback add writes fallback_providers, and an entry missing provider or model is dropped in silence), so the guest never writes it. The key is named, never written: REKAN_MODEL_FALLBACK_API_KEY_ENV empty falls back to the primary's env var, and the value stays in box.env like every other credential. An operator can inspect what a box actually got with hermes fallback list.

Point it at a Hermes-usable context window. Rekan.Ai's qwen36 pool (10.172.55.39) serves the same model on three ports and only two of them can answer a Hermes turn:

portwindow per requestusable as a box's endpoint
:809075,008yes — the deployment default
:80801,048,576yes (nemotron)
:555510,240no — below Hermes' floor

Hermes refuses any model whose window is under 64,000 (MINIMUM_CONTEXT_LENGTH, agent/model_metadata.py): a turn routed to :5555 dies at model init with "context window of 10,240 tokens, which is below the minimum 64,000", and model.context_length cannot override it (the override must itself be ≥ the floor). Restart that instance with a bigger -c — context is shared across its -np slots, so -c 80000 -np 8 yields 10,240 per slot — before pointing anything at it.

  • Toolsets, skills, capabilities — from the workload pack, so two boxes of the same business function behave alike.
  • Guardrails — forbidden and human_approval_required verbatim from the pack. The wizard adds the customer's data classes; it never removes an action from the approval list.
  • Resources, image, LXD project and profile — sized and placed by Rekan.Ai.

# The three answers

# 1. Persona

display_name, role, language (id/en), tone, directives (2–5 short rules), optional signature.

Defaults come from the pack's persona block, so the operator tunes rather than composes. The wizard reproduces them verbatim in box.yaml and the guest writes them into SOUL.md — the file the agent actually reads — not merely into a config key.

# 2. Private data

data_classes (public, internal, confidential, pii, financial, legal_privileged), may_persist_memory, must_cite_sources, allow_private_data_egress (default off), optional standing_instructions.

The classes are also written to guardrails.data_classes, so the box's audit trail and the pack's other guardrails agree. The guest states the policy in SOUL.md in plain sentences ("Private data never leaves this box. Summarise it; do not export it.").

One coherence rule is enforced: a box that may keep memory of pii or legal_privileged material must also be required to cite sources — otherwise the policy contradicts itself.

# 3. Gateway

platform (telegram, slack, lark, discord, teams, email), enabled (off = headless), route, and the platform's credentials.

  • route is not a secret: it is the chat/channel/mailbox the coworker may answer in, and it lands in box.yaml as gateway.route + gateway.route_field (chat_id, channel_id, team_id, inbox_address).
  • Credentials are write-only. Their values go to box.env only. box.yaml declares the credential names (credential_keys, required_credential_keys) so the guest can verify presence without ever holding a value in a non-secret file.
  • An enabled gateway with no route is refused at render time — a box that can answer in any chat it can see is not a configuration a customer should be able to create by accident.

# Endpoints

All routes are under /api/v1/setup and require a bearer token.

# GET /setup/questions?workload=<slug>[&draft=<json>]

The three sections as data: for each field a key, label, type (text|textarea|select|multiselect|bool|secret|secret_map|list), required, default, help, and options where the answer is a choice. Defaults come from the pack; draft (optional JSON) overrides them, which is what lets a panel refresh step 3 after step 1 changed the platform.

GET /setup/workloads/{slug}/questions is the same payload for a known pack.

# POST /setup/preview

Body: the draft — {workload_slug, name?, persona, private_data, gateway}, or {workload_slug, answers: {persona, private_data, gateway}}.

Returns box_yaml (the exact bytes the guest will receive), secrets (values replaced with a fixed mask, names kept), secret_names, hard_set, create_request (the payload a deploy would send, likewise masked), warnings, and fingerprint.

422 on a draft that is not deployable, shaped {"detail": {"message", "section", "errors": {field: sentence}}} where section is persona, private_data or gateway.

# POST /setup/draft/verify

Body: the draft plus the fingerprint the panel holds. 200 with {"fingerprint", "matches", "summary", "idempotency_key"} when it still matches, 409 when the answers changed since the preview. summary is credential-free.

# GET /setup/draft/{fingerprint}?draft=<json>

Drafts are stateless: the control plane never stores one, so it can never hand back a draft with credential values in it. Pass the draft body and this verifies it against the fingerprint (404 if it no longer matches). Without a body it returns {"stateless": true, "detail": …} rather than pretending to look something up.

# POST /setup/deploy (alias: POST /setup)

Same semantics as POST /api/v1/boxes: 202 BoxJobOut, and a CREATE_BOX job carries the answers. idempotency_key replays return the original box + job.

The job payload holds:

{
  "workload_slug": "finance-cfo",
  "secrets": {"TELEGRAM_BOT_TOKEN": "…"},   // pushed to box.env, then scrubbed
  "answers": {"workload_slug", "name", "sections": {"persona", "private_data", "gateway"}},
  "draft_fingerprint": "…",
  "requested_by": "[email protected]"
}

# How a secret travels

  1. The value arrives in a request body and is validated.
  2. It appears in exactly one server-side place: the CREATE_BOX job payload.
  3. The worker pushes it into /etc/rekan/box.env (mode 0600, owned by the box user) and then the job row is scrubbed (secrets_scrubbed: true).
  4. No event, log line, error message or response body ever contains it. The preview masks it; a validation error names the field, never the value; the credential never appears in box.yaml (and therefore never in the guest's apply-marker hash input).

A fingerprint deliberately does not depend on a credential value: rotating a token is not a configuration change, so it must not invalidate the operator's draft.

# Guest-side apply

image/guest/rekan-box-apply is where the answers take effect. It is idempotent and safe to re-run.

  • Writes ~/.hermes/.env from box.env (0600).
  • Applies the declarative config: block through hermes config set — best-effort, so an unknown key on an older Hermes build degrades the box instead of failing the apply.
  • Writes SOUL.md with the persona (name, role, language, tone, directives, signature) and the private-data policy.
  • Applies the gateway answer: the route gate as the platform's allowlist variable in ~/.hermes/.env (<PLATFORM>_ALLOWED_CHATS for a group/channel route, <PLATFORM>_ALLOWED_USERS for a DM) — the only shape a live box was measured to read; see docs/gateway-setup.md for the config keys that turned out to be no-ops. Credential names are read from box.yaml when it declares them, falling back to the image's built-in map for a legacy file.
  • Installs the gateway as root (a --system unit needs root; as the box user the install is refused and the old fallback started a manual gateway that died with the apply) and drives the unit with systemctl — enabled, and restarted so a re-apply picks up the new config, which is what makes it come back after a reboot. It runs as the box user inside the unit (--run-as-user), and the files the root install writes into the box's hermes home are handed back to the box user.
  • Starts a gateway only when its credentials are actually in .env: a gateway with no token cannot connect to anything, and hermes gateway restart blocks on it long enough to look like a hung apply.
  • Writes /var/lib/rekan/.apply-done last, holding the SHA-256 of the box.yaml it applied. The control plane only reports READY when that hash matches what it pushed.

The image path also honours REKAN_STATE_DIR, REKAN_HERMES_HOME and REKAN_HERMES_BIN, which is how tests/test_guest_apply.py runs the real script against a sandbox without touching a live box.

# Re-apply

POST /api/v1/boxes/{id}/actions {"action":"reapply","secrets":{…}} re-renders from the box's original answers (read back from the job row that created it), not from the pack defaults. Rotating a token therefore keeps the persona, the private-data policy and the gateway route intact — the box.yaml bytes are unchanged and only box.env is rewritten.

A box created through POST /api/v1/boxes has no answers; its re-apply falls back to the pack render, and its box.yaml carries no persona/private_data sections.

# Where the code lives

PieceFile
Draft model, validation, render, preview, round-triprekan/services/setup_wizard.py
HTTP step sequencerekan/api/setup.py
Admission into the job queuerekan/api/boxes.py, rekan/services/worker.py
Answers → guest filerekan/services/provisioner.py (box_yaml_for, _answers_for)
Applying the answers inside the boximage/guest/rekan-box-apply
Teststests/test_deploy_wizard.py, tests/test_wizard_support.py, tests/test_guest_apply.py

# Tests

cd control-plane && .venv/bin/python -m pytest -q

The wizard-specific suites run the real guest script in a sandbox (tests/test_guest_apply.py), assert that credentials never reach box.yaml, events, responses or shared strings, and prove that a preview and a deploy of the same draft produce identical bytes.