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
- A Linux x86_64 host where you can use
sudo. The tests run on Debian 12. - Docker with Compose v2.20 or newer. The daemons, the background programs of arizuko, run as Docker containers.
- Hardware virtualization, with
/dev/kvm,/dev/vhost-vsockand/dev/net/tunavailable.runedis the daemon that runs turns. It starts each turn as a KVM guest, a small virtual machine, withqemu-system-x86_64. Without/dev/kvm,runeddoes not start. - Memory for the guests.
RUNED_GUEST_MEMtimesMAX_CONCURRENT_CONTAINERSmust not exceed the memory of the host, orruneddoes not start. The defaults are 2 GiB and 5 guests, so they need 10 GiB. - Go 1.21 or newer, to build the
arizukocommand and the daemons. Go fetches the pinned 1.27 toolchain on the first build when the host can reach go.dev. - Free disk under
/srv/data/for the guest rootfs, the root filesystem of a guest. One rootfs tree is about 7 GB.sudo arizuko images -i <name> --pruneremoves the trees the instance does not use. - A key for the model provider, for example an Anthropic API key, and a token for one chat platform. arizuko is free. The model provider bills the usage.
- A public domain and a TLS certificate, the file that lets browsers trust HTTPS, only for web chat, OAuth login or inbound webhooks. A local instance needs neither.
- No open ports by default. The channel adapters use the docker network. Only when
WEB_PORTis set doesproxydpublish that port on the host.
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_SECRETandSECRETS_KEY. The file mode is0600. 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 understore/extenddb_db/. One copy of the data directory therefore holds every row. Each daemon writes its own rows at its first start:routdwrites the starting grants, the starting list of allowed hosts and the default groupmain. groups/main/- The home of the first agent, with
.claude/andlogs/. A product adds its files here, for examplePERSONA.mdandfacts/. web/pub/main/andweb/priv/main/- The public and the private web folder of the group. A group with the
web:publishgrant sees them as~/public_htmland~/private_html. services/andsurrogate/- 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.
- Slack: a Slack app, the signing secret and the Events API.
- Discord: a bot token, mention-only channels and threads.
- Asymmetric channels: messages come in on one platform, and replies go out on another.
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
runeditself 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,
mainorcorp/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.