arizuko

arizuko › how-to

getting started

This guide takes a new host to a running instance with one channel and one invited user. One block of commands does the install. The sections after the block explain each part. The section Eight terms recur in these guides defines group, agent, turn, channel, skill, grant, JID and route token.

The host needs KVM, Docker, Go and enough memory

Create an instance and configure its store

Clone the repository. The daemon images, the guest kernel and the agent rootfs build from it. Nothing mounts the checkout at run time, because the image contains the agent code from ant/. make images and make kernel need sudo. ./arizuko runs as you.

A new instance needs a store and initialized owner tables before the first package add. Run the following commands from the repository as the UID 1000 instance owner. Change demo if that instance exists. The commands refuse to overwrite its directory.

The commands build the daemon image and guest artifacts in the new instance. Enter the absolute artifact directories printed by the build when prompted. The bootstrap creates scoped keys and saves the store administrator password in env/store-admin.env. Keep the instance and temporary bootstrap directory private. If a command fails, inspect both directories before retrying. Sync may report missing keys for other daemons; enroll each one before enabling it.

A disposable test ran package add and package sync and restarted the seven services using a separate image and host ports. The test reused read-only guest artifacts. It did not test the release image build, arizuko run, or guest execution.

# Run from the repository as the instance owner, with UID 1000.
set -Eeuo pipefail
umask 077
source_dir=$PWD
flavor=demo
project="arizuko_${flavor}"
data="/srv/data/arizuko_$flavor"
[ "$(id -u)" = 1000 ] || { echo 'Use the UID 1000 instance owner.'; exit 1; }
# mkdir fails if the instance already exists. Never reuse an instance here.
sudo mkdir -p /srv/data
sudo mkdir -m 0711 -- "$data"
sudo chown "$(id -u):$(id -g)" "$data"
proof=$(mktemp -d)
mkdir "$proof/bin"
# Stop only the bootstrap owner processes if the commands fail.
cleanup_owners() {
  for name in onbod routd authd; do
    if [ -f "$proof/$name.pid" ]; then
      pid=$(cat "$proof/$name.pid"); kill "$pid" 2>/dev/null || true
      wait "$pid" 2>/dev/null || true
    fi
  done
}
trap cleanup_owners EXIT
freeport() { python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()'; }
# Keep the CLI in the checkout for later commands.
go build -o ./arizuko ./cmd/arizuko
go build -o "$proof/bin/authd" ./authd
go build -o "$proof/bin/routd" ./routd/cmd/routd
go build -o "$proof/bin/onbod" ./onbod
port=$(freeport)
env -i PATH=/usr/bin:/bin HOST_APP_DIR="$source_dir" ./arizuko create -i "$flavor" > "$proof/create.log" 2>&1
sudo make images DATA_DIR="$data"
sudo make kernel DATA_DIR="$data"
read -r -p 'Guest kernel directory: ' kernel_dir
read -r -p 'Guest rootfs directory: ' rootfs_dir
sudo test -s "$kernel_dir/bzImage" && sudo test -s "$kernel_dir/initrd.gz"
[ -d "$rootfs_dir" ]
store_db_password=$(openssl rand -hex 24); store_app_password=$(openssl rand -hex 24)
printf 'HOST_APP_DIR=%s\nDYNAMODB_URL=https://extenddb:18443\nAWS_REGION=us-east-1\nSTORE_HOST_PORT=%s\nSTORE_DB_PASSWORD=%s\nSTORE_APP_PASSWORD=%s\n' "$source_dir" "$port" "$store_db_password" "$store_app_password" >> "$data/.env"
printf 'APP=arizuko\nFLAVOR=%s\nDATA_DIR=%s\nSTORE_HOST_PORT=%s\nSTORE_DB_PASSWORD=%s\nSTORE_APP_PASSWORD=%s\n' "$flavor" "$data" "$port" "$store_db_password" "$store_app_password" > "$proof/compose.env"
mkdir -p "$data/store/extenddb" "$data/store/extenddb_db"
sudo chown 1000:1000 "$data/store/extenddb" "$data/store/extenddb_db"
chmod 711 "$data" "$data/store"
sudo docker compose -p "$project" --env-file "$proof/compose.env" -f template/services/extenddb/extenddb.yml up -d --wait extenddb_db > "$proof/store-db.log" 2>&1
export EXTENDDB_PG_PASSWORD="$store_db_password" EXTENDDB_APP_PASSWORD="$store_app_password"
# Init prints credentials; hold output in memory and write only a private env file.
init_output=$(sudo --preserve-env=EXTENDDB_PG_PASSWORD,EXTENDDB_APP_PASSWORD docker compose -p "$project" --env-file "$proof/compose.env" -f template/services/extenddb/extenddb.yml run --rm --no-deps -e EXTENDDB_PG_PASSWORD -e EXTENDDB_APP_PASSWORD extenddb init --pg-host extenddb_db --pg-port 5432 --pg-user "arizuko_$flavor" --extenddb-user "arizuko_${flavor}_app" --bind-addr 0.0.0.0 --tls-san extenddb --config /var/lib/extenddb/extenddb.toml 2>/dev/null)
printf '%s' "$init_output" | python3 -c 'import re,sys; s=sys.stdin.read(); p=re.search(r"Password:\s*([^\s│]+)",s).group(1); a=re.search(r"Account ID:\s*(\d+)",s).group(1); print("EXTENDDB_PASSWORD="+p+"\nSTORE_ACCOUNT_ID="+a)' > "$proof/admin.env"
unset init_output
sudo docker compose -p "$project" --env-file "$proof/compose.env" -f template/services/extenddb/extenddb.yml up -d --wait extenddb > "$proof/store-server.log" 2>&1
set -a; . "$proof/admin.env"; set +a
container=$(sudo docker compose -p "$project" --env-file "$proof/compose.env" -f template/services/extenddb/extenddb.yml ps -q extenddb)
sudo --preserve-env=EXTENDDB_PASSWORD docker exec -e EXTENDDB_PASSWORD "$container" extenddb settings -c /var/lib/extenddb/extenddb.toml set control_plane_delay_seconds 0 >/dev/null 2>&1
for user in authd routd onbod operator runed; do
  case "$user" in
    authd) resources='["arn:aws:dynamodb:*:*:table/authd.*"]';;
    routd) resources='["arn:aws:dynamodb:*:*:table/routd.*"]';;
    onbod) resources='["arn:aws:dynamodb:*:*:table/onbod.*","arn:aws:dynamodb:*:*:table/routd.*"]';;
    operator) resources='["arn:aws:dynamodb:*:*:table/authd.*","arn:aws:dynamodb:*:*:table/onbod.*","arn:aws:dynamodb:*:*:table/routd.*"]';;
    runed) resources='["arn:aws:dynamodb:*:*:table/runed.*"]';;
  esac
  policy="{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"dynamodb:*\",\"Resource\":$resources}]}"
  sudo --preserve-env=EXTENDDB_PASSWORD docker exec -e EXTENDDB_PASSWORD "$container" extenddb manage -c /var/lib/extenddb/extenddb.toml --user admin create-user --account-id "$STORE_ACCOUNT_ID" --user-name "$user" >/dev/null 2>&1
  sudo --preserve-env=EXTENDDB_PASSWORD docker exec -e EXTENDDB_PASSWORD "$container" extenddb manage -c /var/lib/extenddb/extenddb.toml --user admin put-user-policy --account-id "$STORE_ACCOUNT_ID" --user-name "$user" --policy-name tables --policy-document "$policy" >/dev/null 2>&1
  key=$(sudo --preserve-env=EXTENDDB_PASSWORD docker exec -e EXTENDDB_PASSWORD "$container" extenddb manage -c /var/lib/extenddb/extenddb.toml --user admin create-access-key --account-id "$STORE_ACCOUNT_ID" --user-name "$user" 2>/dev/null)
  printf '%s' "$key" | python3 -c 'import json,sys; x=json.load(sys.stdin); print("AWS_ACCESS_KEY_ID="+x["access_key_id"]+"\nAWS_SECRET_ACCESS_KEY="+x["secret_access_key"])' > "$proof/$user.env"
  unset key
done
sudo cat "$data/store/extenddb/.extenddb/tls/cert.pem" | base64 -w0 > "$proof/ca"
printf 'AUTHD_SERVICE_KEY=%s\n' "$(openssl rand -hex 32)" > "$proof/service.env"
printf 'STORE_CA=%s\nRUNED_KERNEL_DIR=%s\nRUNED_ROOTFS_DIR=%s\n' "$(cat "$proof/ca")" "$kernel_dir" "$rootfs_dir" >> "$data/.env"
mkdir -p "$data/env"
for name in authd routd onbod runed; do cp "$proof/$name.env" "$data/env/$name.env"; done
cp "$proof/operator.env" "$data/env/operator.env"
printf 'DYNAMODB_URL=https://127.0.0.1:%s\n' "$port" >> "$data/env/operator.env"
common=(PATH=/usr/bin:/bin HOST_APP_DIR="$source_dir" DYNAMODB_URL="https://127.0.0.1:$port" AWS_REGION=us-east-1 STORE_CA="$(cat "$proof/ca")")
health() { local name=$1 hp=$2; for n in $(seq 1 60); do if curl -fsS "http://127.0.0.1:$hp/health" >/dev/null 2>&1; then return 0; fi; kill -0 "$(cat "$proof/$name.pid")" 2>/dev/null || return 1; sleep 1; done; return 1; }
auth_port=$(freeport); rout_port=$(freeport); onbod_port=$(freeport)
set -a; . "$proof/authd.env"; . "$proof/service.env"; set +a
env -i "${common[@]}" DATA_DIR="$data" AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" LISTEN_ADDR="127.0.0.1:$auth_port" AUTHD_SERVICE_KEYS="service:onbod=$AUTHD_SERVICE_KEY" "$proof/bin/authd" > "$proof/authd.log" 2>&1 & printf '%s\n' "$!" > "$proof/authd.pid"
health authd "$auth_port"
set -a; . "$proof/routd.env"; set +a
env -i "${common[@]}" DATA_DIR="$data" AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" LISTEN_ADDR="127.0.0.1:$rout_port" "$proof/bin/routd" > "$proof/routd.log" 2>&1 & printf '%s\n' "$!" > "$proof/routd.pid"
health routd "$rout_port"
set -a; . "$proof/onbod.env"; set +a
env -i "${common[@]}" DATA_DIR="$data" AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" ONBOD_LISTEN_ADDR="127.0.0.1:$onbod_port" AUTHD_URL="http://127.0.0.1:$auth_port" AUTHD_SERVICE_KEY="$AUTHD_SERVICE_KEY" ROUTER_URL="http://127.0.0.1:$rout_port" "$proof/bin/onbod" > "$proof/onbod.log" 2>&1 & printf '%s\n' "$!" > "$proof/onbod.pid"
health onbod "$onbod_port"
for name in onbod routd authd; do kill "$(cat "$proof/$name.pid")"; wait "$(cat "$proof/$name.pid")" || true; rm "$proof/$name.pid"; done
printf 'owner health passed\n'
# Preserve the one-time admin credentials for later store management.
cp "$proof/admin.env" "$data/env/store-admin.env"
env -i PATH=/usr/bin:/bin ./arizuko package add -i "$flavor" catalog:extenddb
env -i PATH=/usr/bin:/bin ./arizuko package sync -i "$flavor"
# The instance is new; give its runtime files to the daemon user.
sudo chown -R 1000:1000 "$data/packages" "$data/groups" "$data/app-src" "$data/surrogate" "$data/web"
sudo docker compose -p "$project" --project-directory "$data" --env-file "$data/.env" \
  -f "$data/docker-compose.yml" --profile onbod --profile timed up -d --wait --wait-timeout 120
sudo docker compose -p "$project" --project-directory "$data" --env-file "$data/.env" \
  -f "$data/docker-compose.yml" --profile onbod --profile timed ps
printf 'Private bootstrap files remain in %s; protect or remove them after saving the admin credentials.\n' "$proof"

go install github.com/kronael/arizuko/cmd/arizuko@latest installs only the CLI. A full deploy still needs the repository for the images.

make images is the longest step, several minutes on the first run. It builds the daemon images and arizuko-ant:latest, the agent image. Then it exports the guest rootfs into DATA_DIR. make kernel builds the guest kernel once. Without DATA_DIR, make kernel does not start, and make images builds every image and then fails at the rootfs export. arizuko run starts the stack for initial setup. For later deployments, restart the systemd service. For Kubernetes, read Kubernetes.

The instance is one directory under /srv/data

Pick a short name. The name becomes the data directory and the systemd unit name. ./arizuko create -i demo makes /srv/data/arizuko_demo/ with these entries:

.env
The instance configuration, with a new AUTH_SECRET and SECRETS_KEY. The file mode is 0600. Add API keys here later.
store/
Empty until the store server runs. Then it holds the server configuration and certificate under store/extenddb/ and the PostgreSQL data under store/extenddb_db/. One copy of the data directory therefore holds every row. Each daemon writes its own rows at its first start: routd writes the starting grants, the starting list of allowed hosts and the default group main.
groups/main/
The home of the first agent, with .claude/ and logs/. A product adds its files here, for example PERSONA.md and facts/.
web/pub/main/ and web/priv/main/
The public and the private web folder of the group. A group with the web:publish grant sees them as ~/public_html and ~/private_html.
services/ and surrogate/
The installed compose fragments and your own OAuth provider files.

Add -p/--product <name>, for example --product personal, to record the product as ARIZUKO_DEFAULT_PRODUCT in .env. The first boot of routd selects the product for the main folder, and the next arizuko package sync installs its files. The products section lists the catalog.

Every daemon keeps its tables on one store server

The store server is ExtendDB over PostgreSQL. It speaks the DynamoDB protocol. Every daemon reads its address from DYNAMODB_URL and does not start without it. The extenddb package adds both containers. You run the init of the server yourself, because it prints an admin password and the account id only once. The bootstrap keeps them in the private file env/store-admin.env.

Each daemon signs in to the server with its own access key in env/<daemon>.env. A policy limits each key to the tables of that daemon. You make these keys by hand with extenddb manage, one for each daemon. [INSTALL.md] § The per-daemon store keys lists the daemons and the commands. A daemon without a key stops at start, and the instance then restarts in a loop. arizuko generate logs store enrolment is partial with the names of the daemons that have no key. Read that line before you continue.

The systemd service runs the instance

The bootstrap starts the instance. For later deployments, run sudo systemctl restart arizuko_demo. The systemd service starts the store, runs the generator from the release image, and starts Docker Compose. The [INSTALL.md] has the unit file. Check the instance status:

./arizuko status -i demo

The journal holds the service logs: journalctl -u arizuko_demo. When the Docker log driver is none, docker logs shows nothing.

A channel adapter brings messages in

An instance without a channel adapter receives no messages. Pick one platform to start. Each guide shows the app or bot setup on the platform, the variables for .env, and the arizuko group add command that connects a chat to a folder.

The repository has an adapter package for Telegram, WhatsApp, Slack, Discord, email and GitHub. Each daemon directory holds a README.md with its setup, for example teled/ or whapd/.

An invite link gives a user a folder

Make a one-time invite link. The target glob, a pattern with wildcards, sets the folders that the new user gets. main/** means the main group and every group under it.

./arizuko invite -i demo create 'main/**' --max-uses 1 --expires 24h

The command prints a token. Send https://<your-host>/invite/<token> to the user. The user signs in with an OAuth provider, such as GitHub or Google, and confirms on the next page. onbod then grants the user the admin role on the target folder. arizuko invite -i demo list shows the invites with a reference for each. arizuko invite -i demo revoke <ref> cancels one. The invite path needs ONBOARDING_ENABLED=true and a set WEB_PORT, because the link opens in a browser through proxyd.

Two checks show that the agent answers

From the host, send a message and wait for the reply:

./arizuko send -i demo main "ping" --wait

From the channel, send a message in the connected chat and look for it in the log of routd:

sudo journalctl -u arizuko_demo --since "2 min ago" --no-pager | grep -iE 'mention|spawn|reply'

These log lines show a problem:

this host cannot run turns
A condition of the substrate is missing. The message names each one. reference/env § runed lists the full set.
guest did not open ctl within
The guest started and never connected back to runed.
staging tree: mount source
The turn mounts a path that runed itself cannot open.
circuit breaker open
One folder failed too many turns in sequence.

Operate lists each incident with its log line, its cause and its fix.

A rows file changes the configuration and a dump backs up everything

The configuration of an instance is data. Read a resource as YAML, edit the rows, and write them back. Keep the file in git.

arizuko get -i demo groups > rows.yml   # the rows of one resource
git add rows.yml && git commit -m "demo groups"
arizuko plan -i demo rows.yml           # dry run: what would change
arizuko apply -i demo rows.yml          # write only the rows in the file

The command apply never deletes a row that the file does not name. A rows file leaves out secret values, route tokens, invites, message history and the files of the agents. arizuko dump -i demo writes one tar that holds all of them. The files of an agent are plain files under groups/<folder>/: the persona, the memory, the skills and the diary.

Eight terms recur in these guides

group
A workspace with files, a diary, skills and one agent. Its folder name is, for example, main or corp/eng/sre.
agent
The Claude Code process of a group. It runs in its own KVM guest, one guest for each turn. The guest starts on demand and stops when the turn ends.
turn
One run of an agent. A KVM guest starts, answers the new messages, and powers off.
channel
The place where messages come in and go out: Slack, Discord, Telegram, web or email. Each adapter is a separate daemon.
skill
A Markdown file under groups/<folder>/.claude/skills/ that the agent loads at start. One skill gives one capability.
grant
A row that lets one person or agent do one action on a set of folders. Read grants.
JID
The address of a chat, for example telegram:group/123456789. The part before : names the platform.
route token
A credential in a URL that points to a group. /chat/<token>/ opens the guest chat page. /hook/<token> accepts webhooks. /slink/<token> answers with a 308 redirect to /chat/<token>/. Read route tokens.

The other guides cover operation and integration

Operate
The journal, the four daemons that a message passes, and one fix for each incident.
Upgrade
Build the images, set the paths of the guest files, restart.
Talking to agents
Mentions, slash commands, sticky routing and MCP tools.
Build a product
One directory of files that you apply to a group.
Chat link
Visitor chat through /chat/<token>/.
Chat SDK
The conversation inside your own page, through arizuko-client.js.
Webhooks
GitHub, Linear and alert events through /hook/<token>.
Connectors
An external API as an agent tool, with the credential kept on the host.
OAuth providers
Tools that act as the connected user, for example in Slack, Google or Notion.
WebDAV
The workspace in rclone or curl.
Kubernetes
The Helm chart, its volumes and its health checks.

Concepts explains routing, auth, grants and route tokens. Reference lists the CLI, the env vars, the MCP tools and the schema.

Audit mode stops outbound messages

Two variables on routd mute a group or a platform. The agent still runs turns, and every outbound row goes into messages. No message reaches the channel.

SEND_DISABLED_GROUPS
A comma-separated list of folder names. Example: SEND_DISABLED_GROUPS=atlas,research.
SEND_DISABLED_CHANNELS
A comma-separated list of platform prefixes, the part of a JID before :. Example: SEND_DISABLED_CHANNELS=discord.

Inbound messages do not change. The agent does not know about the mute, because reply and send return success. You see the rows at /dash/activity/ and through the inspect_messages MCP tool. Sources: routd/server.go for the group mute, routd/deliver.go for the channel mute.