# 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.
| Step | Question | Answer becomes |
|---|---|---|
| 1 | Persona | persona: in box.yaml → identity in the coworker's SOUL.md |
| 2 | Private data | private_data: in box.yaml → data policy in SOUL.md and guardrails.data_classes |
| 3 | Gateway | gateway: 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 byREKAN_MODEL_API_KEY_ENV) and is injected into every box'sbox.envon the way through —POST /boxesandPOST /setup/deployboth 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=falseremoves it). It is not a variant of the primary: a fallback is a different endpoint serving a different model, sobox.yamlcarries it as its own block and the guest writes it to Hermes' top-levelfallback_providerschain — one entry of{provider, model, base_url, key_env}— rather than tomodel.*keys.fallback_modelis the legacy key (Hermes still merges it, buthermes fallback addwritesfallback_providers, and an entry missingproviderormodelis dropped in silence), so the guest never writes it. The key is named, never written:REKAN_MODEL_FALLBACK_API_KEY_ENVempty falls back to the primary's env var, and the value stays inbox.envlike every other credential. An operator can inspect what a box actually got withhermes 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:
| port | window per request | usable as a box's endpoint |
|---|---|---|
:8090 | 75,008 | yes — the deployment default |
:8080 | 1,048,576 | yes (nemotron) |
:5555 | 10,240 | no — 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 —
forbiddenandhuman_approval_requiredverbatim 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.
routeis not a secret: it is the chat/channel/mailbox the coworker may answer in, and it lands inbox.yamlasgateway.route+gateway.route_field(chat_id,channel_id,team_id,inbox_address).- Credentials are write-only. Their values go to
box.envonly.box.yamldeclares 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
- The value arrives in a request body and is validated.
- It appears in exactly one server-side place: the
CREATE_BOXjob payload. - 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). - 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/.envfrombox.env(0600). - Applies the declarative
config:block throughhermes config set— best-effort, so an unknown key on an older Hermes build degrades the box instead of failing the apply. - Writes
SOUL.mdwith 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_CHATSfor a group/channel route,<PLATFORM>_ALLOWED_USERSfor a DM) — the only shape a live box was measured to read; seedocs/gateway-setup.mdfor the config keys that turned out to be no-ops. Credential names are read frombox.yamlwhen it declares them, falling back to the image's built-in map for a legacy file. - Installs the gateway as root (a
--systemunit 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 withsystemctl— 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, andhermes gateway restartblocks on it long enough to look like a hung apply. - Writes
/var/lib/rekan/.apply-donelast, holding the SHA-256 of thebox.yamlit 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
| Piece | File |
|---|---|
| Draft model, validation, render, preview, round-trip | rekan/services/setup_wizard.py |
| HTTP step sequence | rekan/api/setup.py |
| Admission into the job queue | rekan/api/boxes.py, rekan/services/worker.py |
| Answers → guest file | rekan/services/provisioner.py (box_yaml_for, _answers_for) |
| Applying the answers inside the box | image/guest/rekan-box-apply |
| Tests | tests/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.