Savi Comms

This is the install guide for Savi Comms 2026.11.5, the same text the download carries at docs/INSTALL-SERVER.md.  It is written so an AI assistant can walk you through the install:  give your assistant this page's address and it can follow the steps with you.

Installing Savi Comms on a Linux server in Azure

For:  an AI assistant helping a company's administrator install Savi Comms on a Linux server in Microsoft Azure, reachable over the internet, with Microsoft Teams connected.  A person following it alone can use it too.

How to use this document

You are helping a person, called the administrator below, install software for their company.  You know nothing about this product beyond what is written here, and you do not need to.

Parts 0 through 7 are the install, and they run in order.  Part 8 is for later, when a newer version arrives; nothing in it belongs in a first install.  Part 9 is for when the administrator decides to give each person, assistant and agent session an account of its own on the server.  Part 10 is only for a server installed from an earlier copy of this guide, where Front Door still reaches it over plain http on port 80.  Each step says who does it:

Rules for the whole install:

1.  Run commands exactly as written, replacing only the values in angle brackets, such as <SERVER_NAME>. 2.  Check every step with the check it gives before moving on.  A step whose check fails is not done. 3.  When a check fails, stop.  Show the administrator the command, what it printed, and the check it failed.  Do not guess at a fix and carry on.  The table at the end covers the failures that are known. 4.  Secrets go only where the step says.  A client secret or a sign-in code is typed by the administrator into the console, or written to the one file a step names.  Never paste one into a chat, a ticket, or a log. 5.  Do not open ports, create resources, or change settings this document does not ask for.

What the server needs

This guide installs on a server in Microsoft Azure.  To install on one machine of your own, a Mac or a Linux machine or virtual machine used from its own screen or over SSH, use docs/QUICKSTART.md instead.  The two guides agree on everything they share.

What Needed
Operating system Linux with systemd.  This guide uses Ubuntu 24.04 LTS.  The distributions tested are listed on savicomms.com, under Downloads, then Linux
Python 3.12 or 3.13.  Each release carries compiled code built for exactly these, and an update refuses, before it changes anything, a Python it has no build for.  The container image uses 3.12
Disk encryption The disk that holds the store must be encrypted, or the broker refuses to start.  An Azure virtual machine's managed disks pass, because Azure encrypts them itself:  the broker asks Azure's instance metadata service, and the log says the platform attested it rather than the machine.  LUKS (dm-crypt) also passes
Claude Code Installed for the same user that runs Savi Comms (Part 2.4).  No version is refused.  This release's console sign-in is tested with Claude Code 2.1.282 to 2.1.309; with any other version, a sign-in that fails names the version that ran
Ports The broker answers on 8421 at 127.0.0.1 only.  Caddy answers on 443, open to Front Door only.  Port 22 is the administrator's
Memory and disk 2 processors and 8 GB of memory (Part 1).  16 GB of free disk after Part 2.  Savi Comms itself states no minimum memory

Part 0.  Collect the facts [Assistant, with the Administrator]

Ask the administrator for these, and write the answers down where you can see them for the rest of the install.  Every later step uses them.

Value What it is Example
<PERSON_NAME> The administrator's full name Dana Reyes
<PERSON_EMAIL> Their company email address dana@acme.example
<COMPANY_NAME> The company's name Acme Robotics
<COMPANY_SHORT> A short name for it, one word Acme
<EMAIL_DOMAIN> The part of their email after the @ acme.example
<TIMEZONE> Their timezone, as a tz name America/Chicago
<SECURITY_AGENT> A first name for the security agent, lowercase sentry
<AIR_AGENT> A first name for the agent that looks after workspaces and ways of working, lowercase orchid
<SUBSCRIPTION> The Azure subscription the server and the Teams bot go in (name or id)

The two agent names are the administrator's to choose, not yours.  Ask for them and use what they give you.  If they have no preference, offer the examples above and let them pick; do not name an agent for them.  Neither of these two agents uses a seat:  a license for five seats runs five working agents beside them.

Then find out whether the server already exists.  If the administrator says it does, collect these too and go to Part 1, step 1.2.  If not, Part 1 creates it.

Value What it is
<RESOURCE_GROUP> The Azure resource group holding the server
<VM_NAME> The server's name in Azure
<SERVER_NAME> The server's public DNS name, such as acme-comms.centralus.cloudapp.azure.com
<ADMIN_USER> The Linux user you sign in to it as

Then ask about the company's domain, the part after the @ in their email address, because the server's own certificate is issued for a name inside it:

"Where is the DNS for <EMAIL_DOMAIN> kept?  In Azure, or with another provider, such as the company you bought the domain from?"

Value What it is Example
<DNS_ZONE> The company's domain, as its DNS knows it acme.example
<DNS_IN_AZURE> yes when that domain's DNS is an Azure DNS zone, no otherwise yes
<DNS_GROUP> The resource group that will hold the server's own small DNS zone.  When <DNS_IN_AZURE> is yes, the group holding <DNS_ZONE>; otherwise <RESOURCE_GROUP> acme-dns
<ORIGIN_NAME> The server's own name for Front Door to reach it by: savi-origin. followed by <DNS_ZONE> savi-origin.acme.example

If the company has no domain of its own at all, stop and tell the administrator that this install needs one:  the server's certificate has to be issued for a name the company controls.

Two more come from Part 1, step 1.5, and are written down there: <EDGE_NAME>, the public address everyone uses, and <FDID>, the company's Front Door id.

You need the Azure CLI (az), signed in to the administrator's company directory, on the computer you run from:

az account show --query "{tenant:tenantId, subscription:name, user:user.name}" -o table

Check:  it prints the company's directory and a subscription.  If it says to run az login, ask the administrator to sign you in, then run it again.  Set the subscription:

az account set --subscription "<SUBSCRIPTION>"

Part 1.  The server and its front door [Assistant]

Savi Comms runs on one Linux server that the internet never reaches directly.  Azure Front Door sits in front of it:  Front Door holds the public https certificate and answers the internet, and passes each request to the server over https on port 443.  The server holds a certificate of its own for <ORIGIN_NAME>, which it obtains and renews by itself, and Front Door refuses to talk to it unless that certificate is valid and carries that name.  So nothing crosses between Front Door and the server unencrypted:  not a session cookie, a token, or a message.

The server's network security group opens port 443 to Front Door and to nothing else, and port 80 not at all.  The server itself checks that every request carries this company's own Front Door id, because the address range Front Door uses is shared by every Front Door customer.

Because nothing but Front Door can reach the server, the certificate authority cannot reach it either, so the server proves it controls <ORIGIN_NAME> through DNS instead:  it writes a short-lived TXT record into a small DNS zone that holds only its own name, and the authority reads that record.  Step 1.4 sets that zone up and gives the server permission to write TXT records in it and nowhere else.

The server runs Ubuntu 24.04 LTS, with 2 vCPU and 8 GB of memory.  Port 22 should be reachable only from addresses the administrator controls.

Money.  A Front Door profile adds a monthly charge to the company's Azure bill, and so does the small DNS zone step 1.4 creates (Azure DNS charges per hosted zone per month, plus a small charge per million lookups).  Tell the administrator both before step 1.4, and wait for their yes.  The certificate itself costs nothing.

Disk:  64 GB is what step 1.1 asks for and what a company in daily use should have.  An existing server with less is not a reason to stop.  Ubuntu 24.04's own image default is 30 GB, and the install and a test company's traffic fit in it.  Below 16 GB free after Part 2, stop and tell the administrator the server needs a larger disk.  Step 1.6 checks it.

1.1 Create it (skip if it exists)

az group create -n <RESOURCE_GROUP> -l <REGION>
az vm create -g <RESOURCE_GROUP> -n <VM_NAME> --image Ubuntu2404 --size Standard_B2ms \
    --admin-username <ADMIN_USER> --generate-ssh-keys --public-ip-sku Standard \
    --public-ip-address-dns-name <DNS_LABEL> --os-disk-size-gb 64

<DNS_LABEL> is a short lowercase name, unique in the region, such as acme-comms.  The server's public DNS name is then <DNS_LABEL>.<REGION>.cloudapp.azure.com, and that is <SERVER_NAME>.

1.2 Find the firewall

A server's firewall in Azure is a network security group.  It sits in one of two places:  on the server's own network card, or on the subnet the server shares with others.  Both are normal.  Find which, because every later step needs its name, and because a subnet one is shared and the next step has to be careful with it.

Run these four on the computer with az, in order.  Each one's answer feeds the next.

NIC_ID=$(az vm show -g <RESOURCE_GROUP> -n <VM_NAME> --query "networkProfile.networkInterfaces[0].id" -o tsv)
NSG_ID=$(az network nic show --ids "$NIC_ID" --query "networkSecurityGroup.id" -o tsv)
SERVER_PRIVATE_IP=$(az network nic show --ids "$NIC_ID" --query "ipConfigurations[0].privateIPAddress" -o tsv)
echo "card firewall:  ${NSG_ID:-none}"

If that last line printed none, the firewall is on the subnet.  Get it with these two:

SUBNET_ID=$(az network nic show --ids "$NIC_ID" --query "ipConfigurations[0].subnet.id" -o tsv)
NSG_ID=$(az network vnet subnet show --ids "$SUBNET_ID" --query "networkSecurityGroup.id" -o tsv)

Either way, get its name and remember whether it is shared:

NSG_NAME=$(basename "$NSG_ID")
echo "firewall:  $NSG_NAME    server address:  $SERVER_PRIVATE_IP"

Check:  both values printed and neither is empty.  If NSG_NAME is still empty, the server has no firewall at all.  Stop and tell the administrator:  this document does not create one, and a server with no firewall must not be put on the internet.

Do not use az vm open-port.  It only understands a firewall on the network card, and on a server whose firewall is on the subnet it fails with KeyError: 'networkSecurityGroup' and a traceback.  It also writes rules that allow the whole internet to reach every server on that subnet.  Step 1.3 writes the rules properly instead.

1.3 Open port 443 to Front Door only

This rule lets Front Door, and nothing else, reach port 443 on this server only.  The destination is the server's own address, so a shared subnet firewall leaves the other servers on it exactly as they were.  Port 80 is not opened at all:  nothing Savi Comms does uses it.

az network nsg rule create -g <RESOURCE_GROUP> --nsg-name "$NSG_NAME" -n savi-comms-frontdoor-https \
    --priority 1010 --direction Inbound --access Allow --protocol Tcp \
    --source-address-prefixes AzureFrontDoor.Backend --source-port-ranges '*' \
    --destination-address-prefixes "$SERVER_PRIVATE_IP" --destination-port-ranges 443

If it says the priority is already used, use 1030 and say so to the administrator.  If the firewall is in a different resource group from the server, use that group's name for -g; echo "$NSG_ID" shows which group holds it.

Check:

az network nsg rule list -g <RESOURCE_GROUP> --nsg-name "$NSG_NAME" \
    --query "sort_by([].{priority:priority, name:name, access:access, dir:direction, \
    source:not_null(sourceAddressPrefix, join(',', sourceAddressPrefixes || [''])), \
    ports:not_null(destinationPortRange, join(',', destinationPortRanges || ['']))}, &priority)" -o table

That prints every rule in priority order, lowest number first, which is the order Azure applies them in.  A blank source or port means the rule sets no value there.  Your rule is listed: Allow, from AzureFrontDoor.Backend, port 443.  Read the whole list, not only your rule.  If a rule with a smaller number denies port 443, yours never takes effect; show the administrator the list and stop.  A rule allowing port 80 or 443 from Internet or * is not needed by Savi Comms, and it lets anyone reach the server around Front Door.  So does any rule allowing port 80 from anywhere, AzureFrontDoor.Backend included:  Savi Comms no longer listens there.  Name each such rule to the administrator and leave it for them to decide.

1.4 The server's own name, and its permission to prove it

Front Door reaches the server by the name <ORIGIN_NAME> and checks the server's certificate against it.  This step gives that name a DNS zone of its own, points it at the server, and lets the server, and nothing else, write the TXT records that prove it controls the name.  The server signs in to Azure as itself, with its own managed identity, so no password or secret is stored anywhere.

Why a zone of its own.  Whoever can write TXT records in a zone can prove control of every name in it, to a certificate authority and to other services that verify a domain this way.  A zone holding only <ORIGIN_NAME> means the server's permission reaches its own name and nothing of the company's.

Run these on the computer with az.  First the zone and its name:

DNS_SUB=$(az account show --query id -o tsv)
az network dns zone create -g <DNS_GROUP> -n <ORIGIN_NAME> --parent-name <DNS_ZONE>
PIP_ID=$(az network nic show --ids "$NIC_ID" --query "ipConfigurations[0].publicIPAddress.id" -o tsv)
az network dns record-set a create -g <DNS_GROUP> -z <ORIGIN_NAME> -n @ --target-resource "$PIP_ID" --ttl 300

When <DNS_IN_AZURE> is no, leave --parent-name <DNS_ZONE> off the zone command, because Azure cannot reach the company's other provider.  Instead print the zone's four name servers:

az network dns zone show -g <DNS_GROUP> -n <ORIGIN_NAME> --query nameServers -o tsv

and ask the administrator to add them at their DNS provider, saying:

"Please sign in to the company that runs DNS for <DNS_ZONE>, open its DNS records, and add four records.  Each has the type NS and the name (sometimes called host) savi-origin.  The values are these four, one per record: <the four lines printed above>.  Save, and tell me when it is done."

The A record is an alias of the server's public address, so it follows that address if it ever changes.  When az network nic show prints nothing for PIP_ID, the server has no public address resource of its own; stop and show the administrator.

Now the server's identity and its one permission.  The permission is a role defined here with the five TXT and read actions the certificate challenge uses, rather than Azure's built-in DNS Zone Contributor, which would also let the server change every other record and delete the zone:

VM_PRINCIPAL=$(az vm identity assign -g <RESOURCE_GROUP> -n <VM_NAME> --query systemAssignedIdentity -o tsv)
ZONE_ID=$(az network dns zone show -g <DNS_GROUP> -n <ORIGIN_NAME> --query id -o tsv)
cat > savi-comms-cert-role.json <<EOF
{
  "Name": "Savi Comms certificate challenge (<ORIGIN_NAME>)",
  "Description": "Write and remove the ACME DNS challenge TXT record for the Savi Comms server's own name.",
  "Actions": [
    "Microsoft.Network/dnszones/read",
    "Microsoft.Network/dnszones/recordsets/read",
    "Microsoft.Network/dnszones/TXT/read",
    "Microsoft.Network/dnszones/TXT/write",
    "Microsoft.Network/dnszones/TXT/delete"
  ],
  "NotActions": [],
  "AssignableScopes": ["/subscriptions/$DNS_SUB/resourceGroups/<DNS_GROUP>"]
}
EOF
az role definition create --role-definition @savi-comms-cert-role.json --query roleName -o tsv
az role assignment create --assignee-object-id "$VM_PRINCIPAL" --assignee-principal-type ServicePrincipal \
    --role "Savi Comms certificate challenge (<ORIGIN_NAME>)" --scope "$ZONE_ID"

Write DNS_SUB down as <DNS_SUBSCRIPTION_ID>; Part 3 needs it.  It is the subscription's id, not a secret.

Check:

az role assignment list --assignee "$VM_PRINCIPAL" --all \
    --query "[].{role:roleDefinitionName, scope:scope}" -o table
dig +short <ORIGIN_NAME> A
az vm show -d -g <RESOURCE_GROUP> -n <VM_NAME> --query publicIps -o tsv

The first prints exactly one row:  the role above, with a scope ending in dnszones/<ORIGIN_NAME>.  Any other row means the identity holds more than this install gave it; show the administrator.  The second and third print the same address.  When <DNS_IN_AZURE> is no, the second can print nothing for up to an hour after the administrator saves the four records; wait and run it again.

If az role definition create or az role assignment create is refused, the account you are signed in with cannot grant permissions in <DNS_GROUP>.  Tell the administrator that someone holding Owner or User Access Administrator on that resource group must run those two commands.  Do not grant the built-in DNS Zone Contributor instead without the administrator's yes.  If the assignment says the role does not exist, wait a minute and run it again:  a role just defined takes a moment to reach every part of Azure.

1.5 Front Door

Ask the administrator whether the company already has a Front Door profile it wants Savi Comms to use.

If it has one, collect its name as <FD_PROFILE> and its resource group as <FD_GROUP>, and create an endpoint of Savi Comms' own on it, so no other service's routes are touched:

az afd endpoint create -g <FD_GROUP> --profile-name <FD_PROFILE> --endpoint-name <FD_ENDPOINT> --enabled-state Enabled

If it has none, and the administrator has said yes to the charge, create one with that endpoint:

az afd profile create -g <RESOURCE_GROUP> --profile-name <FD_PROFILE> --sku Standard_AzureFrontDoor
az afd endpoint create -g <RESOURCE_GROUP> --profile-name <FD_PROFILE> --endpoint-name <FD_ENDPOINT> --enabled-state Enabled

Here <FD_GROUP> is <RESOURCE_GROUP>. <FD_PROFILE> and <FD_ENDPOINT> are short lowercase names, such as acme-edge and acme-comms.  Then, either way, point it at the server:

az afd origin-group create -g <FD_GROUP> --profile-name <FD_PROFILE> -n savi-comms \
    --sample-size 4 --successful-samples-required 3 --additional-latency-in-milliseconds 50
az afd origin create -g <FD_GROUP> --profile-name <FD_PROFILE> --origin-group-name savi-comms -n server \
    --host-name <ORIGIN_NAME> --origin-host-header <ORIGIN_NAME> --https-port 443 \
    --enforce-certificate-name-check true --priority 1 --weight 1000 --enabled-state Enabled
az afd route create -g <FD_GROUP> --profile-name <FD_PROFILE> --endpoint-name <FD_ENDPOINT> -n savi-comms \
    --origin-group savi-comms --supported-protocols Http Https --forwarding-protocol HttpsOnly \
    --https-redirect Enabled --link-to-default-domain Enabled --patterns-to-match "/*"
az afd profile update -g <FD_GROUP> -n <FD_PROFILE> --origin-response-timeout-seconds 120

The origin is <ORIGIN_NAME>, not <SERVER_NAME>, and its host header is the same name:  Front Door connects to the server over https, asks for that name, and refuses the connection unless the certificate the server presents is from a publicly trusted authority and carries that name. --forwarding-protocol HttpsOnly makes every request cross to the server encrypted, whichever way the person reached Front Door. --enforce-certificate-name-check true is Azure's default; it is written out so nobody reads its absence as a choice.  Never set it to false:  a server presenting any trusted certificate for any name would then be accepted.

Check:

az afd origin show -g <FD_GROUP> --profile-name <FD_PROFILE> --origin-group-name savi-comms -n server \
    --query "{host:hostName, header:originHostHeader, https:httpsPort, namecheck:enforceCertificateNameCheck}" -o json
az afd route show -g <FD_GROUP> --profile-name <FD_PROFILE> --endpoint-name <FD_ENDPOINT> -n savi-comms \
    --query forwardingProtocol -o tsv

The first shows <ORIGIN_NAME> twice, 443, and true.  The second prints HttpsOnly.  Read both values back rather than trusting the create commands' exit codes.

The route accepts Http as well as Https, and redirects Http to Https, because a person who types the domain into a browser is sent to http:// first.  A route that supports Https alone answers that request with a 404, and the buyer reads it as a domain that does not resolve.  Measured on savicomms.com on 2026-09-26: https://savicomms.com/health answered 200 while the plain browser request 404ed with no redirect.

The origin group has no health probe on purpose:  with one server there is nothing to fail over to.  If the origin group you were given already has one, and the CLI gives you no way to take it off an existing origin group, point it at the path Part 3 sets aside for it instead:

az afd origin-group update -g <FD_GROUP> --profile-name <FD_PROFILE> -n savi-comms \
    --probe-protocol Https --probe-request-type GET --probe-path /fd-probe --probe-interval-in-seconds 100

Part 3's Caddyfile answers /fd-probe and nothing else without the Front Door id, for exactly this reason.  The three sampling numbers are Azure's own defaults, and Azure refuses to create an origin group without them even when no probe is configured.  With one server they decide nothing, so leave them as they are.  The route has no caching, which is its default and must stay so.  The longer timeout lets a slow step, such as signing the installation in to Claude, finish.

Now read the two values the rest of the install needs:

az afd endpoint show -g <FD_GROUP> --profile-name <FD_PROFILE> --endpoint-name <FD_ENDPOINT> --query hostName -o tsv
az afd profile show -g <FD_GROUP> --profile-name <FD_PROFILE> --query frontDoorId -o tsv

The first is <EDGE_NAME>, such as acme-comms-a1b2c3d4e5f6g7h8.z01.azurefd.net:  the address everyone uses, the administrator, Microsoft's sign-in, and Teams.  The second is <FDID>, a long id with dashes.

Check:  both printed a value.  Write them down with the others.  A new route can take up to twenty minutes to start answering; Part 3 checks it.

If az afd route create says a route with the same patterns already exists, stop and show the administrator:  another service is on that endpoint, and Savi Comms needs one of its own.

If you are running this step again after it failed part way through, expect az afd endpoint create to say the endpoint already exists.  That one is fine:  the endpoint is the part that survives a failed run, and the existing one is the one you want.  Move on to the origin group.  Every other command in this step either succeeds or has left nothing behind.

1.6 Check the server

az vm show -d -g <RESOURCE_GROUP> -n <VM_NAME> --query "{state:powerState, fqdn:fqdns, ip:publicIps}" -o table

Check:  the state is VM running and the DNS name is <SERVER_NAME>.

Now look at how port 22 is reached, in the list you printed in step 1.3, and tell the administrator one of these three:

Either way, do not change the port 22 rule.  It is the administrator's.

Then sign in to the server:

ssh <ADMIN_USER>@<SERVER_NAME>

Check:  you have a shell prompt on the server, and cat /etc/os-release names Ubuntu 24.04.  Every step in Parts 2 and 3 runs on the server, as <ADMIN_USER>, unless it says otherwise.

While you have the prompt, check the disk:

df -h /

Check:  the Avail column shows 16 GB or more.  Less than that, stop and tell the administrator the disk is too small, quoting the number.  A 30 GB disk on a fresh Ubuntu image shows roughly 24 GB available, which is fine.

Part 2.  Install Savi Comms [Assistant]

2.1 The system packages

sudo apt-get update
sudo apt-get install -y python3 python3-venv python3-pip curl caddy

On RHEL 9 or 10, Rocky 9, AlmaLinux 9 or 10, or Amazon Linux 2023, the system's own python3 is older than Savi Comms needs.  Install python3.12 by name instead, and use python3.12 in place of python3 in every later command that names it, including the install_dependencies.sh call in 2.2:

sudo dnf install -y python3.12 python3.12-pip curl caddy

Check: python3 --version (or python3.12 --version on the RHEL family) prints Python 3.12 or Python 3.13, and caddy version prints a version.  Those two are the Pythons every release is built for, because its code is compiled and compiled code runs only under the Python version that built it.  Ubuntu 24.04 ships 3.12 and Debian 13 ships 3.13.  Debian 12 ships 3.11 and Ubuntu 22.04 ships 3.10, which no release is built for:  stop and tell the administrator the server needs a newer version of its system.

2.2 The distribution file

The administrator received two files: savi-comms-<VERSION>.tar.gz and savi-comms-<VERSION>.tar.gz.sha256.

<VERSION> is whatever sits between savi-comms- and .tar.gz in the name of the file you were actually sent, however long it is and whatever it contains.  A file named savi-comms-0.1.0-azure.2e6a1a9.tar.gz makes <VERSION> into 0.1.0-azure.2e6a1a9.  Do not shorten it and do not tidy it:  the folder inside the file carries the same name, so a guess makes the mv below fail.  Read it off the file rather than typing what you expect.

Copy both to the server's home folder.  From the computer that has them:

scp savi-comms-<VERSION>.tar.gz savi-comms-<VERSION>.tar.gz.sha256 <ADMIN_USER>@<SERVER_NAME>:~/

Then on the server:

cd ~
sha256sum -c savi-comms-<VERSION>.tar.gz.sha256
tar -xzf savi-comms-<VERSION>.tar.gz
mv savi-comms-<VERSION> savi-comms
cd ~/savi-comms
SAVI_VENV=~/savi-comms-venv sh scripts/install_dependencies.sh install ~/savi-comms python3

On the RHEL family (RHEL 9 or 10, Rocky 9, AlmaLinux 9 or 10, Amazon Linux 2023), name python3.12 instead of python3 on that last line.

Check: sha256sum printed OK.  If it did not, stop:  the file is damaged or is not the one the vendor sent.  The last command ends with install_dependencies: verified and a count of locked releases.

Packages that only one optional feature uses are left out unless you choose it.  The encrypted store is in by default.  If this server will keep its data in a Postgres database, or send mail through an Azure Communication Services resource, put SAVI_EXTRAS=postgres, SAVI_EXTRAS=email or SAVI_EXTRAS=postgres,email at the start of the last command.  An upgrade keeps every feature the Python folder already has.

The program's Python folder, ~/savi-comms-venv, sits beside the program rather than inside it.  An upgrade (Part 8.3) replaces the program's folder whole, and a Python folder inside it would have to be built again, over the network, in the middle of every upgrade.

The service installer records which Python the program runs in one file, releases/interpreter inside the store, and the comms command reads it, so comms keeps working after every upgrade without any step from you.

2.3 The fixed rules [Administrator reads, then Assistant runs]

Savi Comms has a short set of rules nobody at the company can change, the administrator included.  They have to read and accept them before anything is created.

A license or a free trial comes first.  The package is encrypted, and it opens only for an installation holding one or the other.  Ask the administrator for their license, or for the email address a trial should use:  one trial is allowed per company domain, and one per address at a free email provider.  The first time the installer runs it asks, in the terminal:  type 1 to paste the license, or 2 to have a six-digit code sent to that address, and ask the administrator for the code.  This needs the server to reach the internet.  It also needs an available terminal for that question:  started by a tool that runs commands with no terminal, the installer stops and says what it needs, and creates nothing.  In that case have the administrator run the command in their own SSH session.

Run the installer without accepting:

cd ~/savi-comms
export SAVI_COMMS_PUBLIC_URL=https://<PUBLIC_HOSTNAME>
~/savi-comms-venv/bin/python installer.py --store ~/savi-comms-store --workspace-root ~/agent-workspaces \
    --human-name "<PERSON_NAME>" --human-email <PERSON_EMAIL> \
    --company-name "<COMPANY_NAME>" --company-short <COMPANY_SHORT> \
    --security-agent <SECURITY_AGENT> --air-agent <AIR_AGENT>

It prints the rules and creates nothing beyond what holds the license or trial.  Show the administrator every rule it printed, word for word, and say:

"These are the rules Savi Comms holds every agent to, and nobody at your company can change them, including you. Please read them.  Do you accept them?"

Only when they say yes, run the same command with --accept-fixed-rules added before --security-agent.  If they say no, stop the install and tell them it cannot go further without that acceptance.

The export is what makes the installer print the console address the administrator can actually open.  Without it the closing output says https://localhost:8421/console, which is reachable only from a browser on the server itself, and on a published server nobody administering it is ever there. <PUBLIC_HOSTNAME> is the same name Part 1 published (the Front Door endpoint host, or the company name pointed at it).  The same address tells the installer this server sits behind a front door, so the broker stays plain http on 127.0.0.1:8421, which is what Caddy passes requests to in Part 3.

When the company's own name is the public one, such as comms.acme.example, the Front Door endpoint host it sits in front of stays reachable so a session already carrying it still returns cleanly.  Changing the public hostname is two changes rather than one: SAVI_COMMS_PUBLIC_URL on the server, and the redirect URI on the Savi Comms app registration.  Change one and sign-in breaks with a return-address mismatch.

Check:  the output includes this line:

bootstrap complete:  2 agents live, authority retired, audit chain verified=True

and the Console: line near the end carries https://<PUBLIC_HOSTNAME>/console rather than localhost.

The installer runs once.  Running it a second time is refused, and that is correct:  from now on, new agents go through the administrator.  If it failed partway, see the table at the end before running anything again.

2.4 Claude Code

The agents think with Claude, through Claude Code, which has to be installed for the same user:

curl -fsSL https://claude.ai/install.sh | bash
~/.local/bin/claude --version

Check:  it prints a version number followed by (Claude Code).  Do not run claude to sign in here.  The installation signs itself in later, from the console, in Part 5.  A sign-in typed in a terminal here is a second sign-in, and whichever of the two refreshes first retires the other.

This release's console sign-in is tested with Claude Code 2.1.282 to 2.1.309 (CLAUDE_CODE_TESTED in config.py).  A newer version usually works; if the sign-in in 5.3 fails, the console names the version that ran.

2.5 Run it as a service

cd ~/savi-comms
bash scripts/install_linux_service.sh --store ~/savi-comms-store --workspaces ~/agent-workspaces
sudo loginctl enable-linger <ADMIN_USER>

The second command keeps it running when nobody is signed in to the server.

Where it runs from, which is not where you unpacked it.  The installer copies the unpacked folder to ~/savi-comms-store/releases/<VERSION>, points ~/savi-comms-store/releases/current at it, and the service starts that pointer.  An update unpacks the next version beside it and moves the pointer, so the restart runs the new code and going back is the pointer moving again.  Read it back with grep '^ExecStart=' ~/.config/systemd/user/savi-comms.service:  the path has releases/current in it.  A line naming ~/savi-comms instead is a server installed before this changed, where an update lays down a new version and the service keeps running the old one; re-run the command above from the unpacked folder to move it.

One extra argument, only on a server that carries more than one managed identity and stores its rows in Postgres.  Add --database-identity <client id>, naming the user-assigned identity the database knows the application by.  Leave it off and the broker asks the machine for no particular identity, which is right when the machine has one; on a machine with two, the endpoint answers with whichever it likes and the database refuses a principal it has never heard of.  The installer checks the value is a client id, so the identity's name, which looks nothing like one, is rejected here rather than three steps later as an authentication failure.

Creating that identity's database role, for whoever sets the server up.  The pgaadauth_* functions exist only in the postgres database and not in one you have just created, so create the principal from postgres and then do the grants from the application's own database.  Roles are cluster-wide, so the principal is visible from both.  Getting this the other way round fails with function does not exist, which reads like a missing extension rather than like the wrong database.  The role name has to equal the managed identity's name for the sign-in to resolve.  The narrowest grant set that lets the broker run is CONNECT and TEMPORARY on its database, USAGE, CREATE on its schema, nothing on public, and no database-level CREATE; the broker applies its own migrations on startup, which is what the schema-level CREATE is for.  Set the role's search_path to the schema alone.  Naming pg_temp after it would make an unqualified read miss the temporary views the broker's tenant scope is built from and hit the real table instead, returning every tenant's rows.  Now tell it its public address and the administrator's timezone:

mkdir -p ~/.config/systemd/user/savi-comms.service.d
printf '[Service]\nEnvironment=SAVI_COMMS_PUBLIC_URL=https://%s\nEnvironment=SAVI_FLEET_TZ=%s\n' \
    "<EDGE_NAME>" "<TIMEZONE>" > ~/.config/systemd/user/savi-comms.service.d/server.conf
systemctl --user daemon-reload
systemctl --user restart savi-comms

Check:

systemctl --user is-active savi-comms
SAVI_COMMS_ROOT=~/savi-comms-store sh -c '. ~/savi-comms/scripts/broker_address.sh && broker_curl -s "$(broker_url)/health"'

The first prints active.  The second prints a line containing "status":"ok" and "secrets_ok":true.

The second line is this guide's health check, and every later part uses it.  It asks the program where its broker is and which certificate to trust, the same way the program's own scripts ask, so it works whether the connection is encrypted or not.  On a server installed by this guide the broker answers plain http on 127.0.0.1:8421, because Caddy is its front door and encrypts everything that reaches it from outside; grep -c LOOPBACK_TLS ~/savi-comms-store/profile.json prints 0. If it prints 1, the installer ran without SAVI_COMMS_PUBLIC_URL set and the broker answers https, which Caddy cannot pass requests to:  see the table at the end.

The broker checks at every start that the disk holding its store is encrypted.  On an Azure virtual machine with managed disks, journalctl --user -u savi-comms shows disk encryption confirmed for the store.  If it shows Savi Comms will not start: the disk holding its store instead, see the table at the end.

The service answers only on the server itself.  The next part puts it on the internet behind a certificate.

Part 3.  Connect the server to Front Door [Assistant]

Caddy listens on port 443 and nowhere else.  It holds a certificate for <ORIGIN_NAME> that it obtains by itself and renews by itself, refuses any request that does not carry this company's Front Door id, and passes the rest to Savi Comms:  Teams messages to the port that answers Teams and nothing else, everything else to the console.

3.1 The Caddy build that can prove the server's name

The caddy package from Part 2.1 cannot write DNS records, so it cannot obtain the certificate.  This puts Caddy's own build with its Azure DNS module beside it.  The build is pinned:  the script refuses any file whose SHA-256, or whose Caddy and module versions, differ from deploy/caddy/caddy-azure-dns.pin in the distribution.

cd ~/savi-comms
sudo sh scripts/install_caddy_azure_dns.sh install

Check:  it ends with installed. /usr/bin/caddy is v followed by the pinned version and with dns.providers.azure.  If it says REFUSED, stop and show the administrator the line:  the download no longer matches the build the vendor checked, and nothing was installed.  The vendor moves the pin; do not look for another copy of Caddy.

The script installs beside the Debian and Ubuntu package only, on an x86-64 server.  On the RHEL family, or on an Arm server, it stops and says so; stop there too and tell the vendor.

The package's own service file, its caddy user and its folders are unchanged.  To go back to the packaged binary: sudo update-alternatives --set caddy /usr/bin/caddy.default, then sudo systemctl restart caddy.

3.2 The Caddyfile

sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
{
    auto_https disable_redirects
}

https://<ORIGIN_NAME> {
    tls {
        dns azure {
            subscription_id <DNS_SUBSCRIPTION_ID>
            resource_group_name <DNS_GROUP>
        }
    }
    route {
        respond /fd-probe 200
        @notours not header X-Azure-FDID <FDID>
        respond @notours 403
        reverse_proxy /api/teams/* 127.0.0.1:8431
        reverse_proxy 127.0.0.1:8421
    }
}
EOF
sudo systemctl restart caddy

Replace <ORIGIN_NAME>, <DNS_SUBSCRIPTION_ID>, <DNS_GROUP> and <FDID> in that file with the real values.  Keep the tabs, and keep the route block:  it is what makes the id check run before anything else.  Without it, Caddy's own ordering runs the proxy first and the check never refuses.

The dns azure block names no client id or secret, and that is deliberate:  Caddy signs in to Azure as the server's own managed identity from Part 1, step 1.4, and the only thing that identity may do is write and remove TXT records in the server's own zone. auto_https disable_redirects stops Caddy from opening port 80 for redirects; with the DNS challenge it has no other reason to.

The respond /fd-probe 200 line is the one exception to the id check, and it is there for the case in Part 1, step 1.5 where the origin group arrived with a health probe already on it.  Front Door health probes do not carry X-Azure-FDID, so a probe on any other path gets the 403 below, Front Door reads the only origin as unhealthy, and the site is unreachable through the edge while the server itself is fine. /fd-probe is answered by Caddy and reaches Savi Comms not at all, so it discloses nothing and it is not a way past the check:  every other path, /health included, still needs the id.

3.3 Checks

Check, on the server, that Caddy obtained the certificate.  The first one can take two minutes after the restart, while the authority reads the TXT record:

sudo journalctl -u caddy --since "10 minutes ago" --no-pager | grep -E "certificate obtained successfully|could not get certificate|error"
echo | openssl s_client -connect 127.0.0.1:443 -servername <ORIGIN_NAME> 2>/dev/null \
    | openssl x509 -noout -subject -issuer -enddate

The first prints a line with certificate obtained successfully and <ORIGIN_NAME>.  The second prints a subject naming <ORIGIN_NAME>, an issuer that is a public authority such as Let's Encrypt, and a notAfter date in the future.  A line with could not get certificate instead is in the table at the end.

Check, on the server, that nothing listens on port 80:

sudo ss -ltnp | grep caddy

It lists :443 and Caddy's own 127.0.0.1:2019, and no :80.  A :80 line means the Caddyfile is not the one above; write it again.

Check, on the server, that the id check refuses and admits correctly.  These go to the server's own port 443 under the name Front Door uses, so they also prove the certificate is trusted: curl refuses an untrusted one.

curl -s -o /dev/null -w "%{http_code}\n" --resolve <ORIGIN_NAME>:443:127.0.0.1 https://<ORIGIN_NAME>/health
curl -s -H "X-Azure-FDID: <FDID>" --resolve <ORIGIN_NAME>:443:127.0.0.1 https://<ORIGIN_NAME>/health
curl -s -o /dev/null -w "%{http_code}\n" --resolve <ORIGIN_NAME>:443:127.0.0.1 https://<ORIGIN_NAME>/fd-probe

The first prints 403.  The second prints the same "status":"ok" line as Part 2, step 2.5.  The third prints 200, which is what a Front Door health probe gets.  If the first prints anything but 403, stop:  the server would accept requests from any Front Door customer.  If any of them prints 000, curl refused the certificate; run the first check again.

Check, from any computer other than the server:

curl -s https://<EDGE_NAME>/health

It prints the "status":"ok" line, with no certificate warning.  For up to twenty minutes after step 1.5 it can answer with a Front Door error page instead; wait and try again.  If it still fails after that, check step 1.3's rule, that Caddy is running (systemctl is-active caddy prints active), and the certificate check above.

https://<EDGE_NAME>/api/teams/messages answers 502 until Teams is set up in Part 6.  That is correct:  the Teams port opens only once a Teams bot exists.

3.4 The certificate, later

Caddy renews the certificate by itself, well before it runs out, the same way it obtained it.  Nobody has to do anything.  To read where it stands, at any time:

echo | openssl s_client -connect 127.0.0.1:443 -servername <ORIGIN_NAME> 2>/dev/null | openssl x509 -noout -enddate
curl -s -o /dev/null -w "%{http_code}\n" https://<EDGE_NAME>/health

The first, on the server, is the certificate's own end date.  The second, from anywhere, is Front Door's view:  Front Door refuses an origin whose certificate has expired or names something else, so a 200 there means Front Door accepted it.  A notAfter less than seven days away means renewal has been failing; read sudo journalctl -u caddy --since "3 days ago" --no-pager | grep -i certificate and use the table at the end.

Part 4.  The company sign-in application [Assistant]

People sign in to the console with their company Microsoft account.  That needs an application registered in the company's own directory.  Run these from the computer with az:

This registration is the console's sign-in application and nothing else.  It is not the identity the broker itself authenticates with, which is a separate registration created when the server's own credentials are provisioned.  Two registrations can carry similar display names, so resolve either one by application id rather than by name.

az ad app create --display-name "Savi Comms" --sign-in-audience AzureADMyOrg \
    --web-redirect-uris "https://<EDGE_NAME>/console/signin/company/callback" \
    --query appId -o tsv

It prints the application id.  Write it down as <APP_ID>.  Then:

az ad sp create --id <APP_ID> --query id -o tsv
az ad app credential reset --id <APP_ID> --display-name savi-comms --years 1 \
    --query "{secret:password, ends:endDateTime}" -o json

The last command prints the application's secret and the date it stops working.  Do not save or repeat the secret anywhere.  Hand both to the administrator once, when Part 5 step 5.2 asks for them.  If that moment has passed, run the last command again for a new one.

Check:

az ad app show --id <APP_ID> --query "{name:displayName, redirect:web.redirectUris}" -o json

Read the value back every time, and especially when you are changing an existing registration. az ad app update --web-redirect-uris has returned exit zero with no output and changed nothing (measured 2026-09-26).  The same change sent as a Graph PATCH through az rest applied at once.  A success exit from that command is not evidence; the az ad app show above is.

It names Savi Comms and shows exactly one return address: https://<EDGE_NAME>/console/signin/company/callback.

If az ad app create is refused for lack of permission, the account you are signed in with cannot register applications.  Tell the administrator that the account needs the Application Developer role in the directory, or that someone who has it must run these three commands.

Part 5.  The administrator's first sign-in [Administrator, guided by the Assistant]

5.1 Open the console

On the server, as <ADMIN_USER>:

~/.local/bin/comms console --print-only

It prints one line starting with https://<EDGE_NAME>/console?c=.  Give that line to the administrator and say:

"Open this link in your browser.  It signs you in to the console once, and it expires in ten minutes.  Tell me when you see the console."

Check:  they say the console is open and shows Home.  If the link has expired, run the command again for a new one.  This setup link works only until company sign-in is working; after step 5.2 it closes for good, and everyone signs in with their company account.

5.2 Turn on company sign-in

Say to the administrator, one numbered step at a time, waiting for each:

1.  "Click Settings, then find the card called Sign-in." 2.  "Under Who may sign in, type <EMAIL_DOMAIN>, choose Microsoft, and click Change." 3.  "Under Your company's sign-in application, paste this application id: <APP_ID>, and click Change." 4.  "Under Connection, paste the secret I am about to give you into the first box, and this date into the second box: <the date from Part 4, as YYYY-MM-DD>.  Click Replace secret." Give them the secret now. 5.  "Click The return address for your company's directory.  It should show https://<EDGE_NAME>/console/signin/company/callback.  Tell me what it shows." 6.  "Click Test sign-in and sign in with your company account when Microsoft asks."

Check:  in step 5 the address matches exactly.  If it does not, stop:  the public address in Part 2, step 2.5 is wrong.  In step 6 the console reports that the test sign-in worked.

5.3 Sign the installation in to Claude

Say:

1.  "Go to Home, find the Claude card, and click Sign this installation in to Claude.  Click it once." 2.  "Click Open the Claude sign-in page.  Sign in with your company's paid Claude account, and approve." 3.  "Claude shows a code.  Use its copy button so you get the whole code.  It has two parts joined by a # sign." 4.  "Back on the console, paste it into The code Claude showed you and click Finish signing in.  Do this within ten minutes of step 1, and do not click the sign-in button again in between."

Check:  Home says This installation is signed in to Claude. A sign-in made this way lasts a year.

If the console says it could not keep the sign-in, Claude may still have issued one, good for a year.  The administrator withdraws it in the account settings at claude.ai, where sign-ins are listed as Claude Code.  Anthropic publishes no way to remove one particular sign-in, so where it cannot be told apart or removed there, ask Anthropic support at support@anthropic.com to revoke it.  The way round it:  on any computer with Claude Code, run claude setup-token, then on the Claude card choose This installation's own Claude sign-in and paste the token it prints.

If Home asks the administrator to confirm it is them first, have them sign out and sign in again with their company account, then start at step 1 within five minutes.

Part 6.  Microsoft Teams [Administrator, guided by the Assistant]

Savi Comms creates its Teams bot itself, in the company's Azure subscription, using the administrator's own Azure sign-in for the few minutes it takes.

Say, one step at a time:

1.  "Go to Settings and find the Teams card.  In the address box, type https://<EDGE_NAME> and click Save the address." 2.  "Click Connect your Azure account, sign in, and accept the consent screen." 3.  "Choose <SUBSCRIPTION> in the list, and click Create the bot." 4.  "Click Download the Teams app package.  A file called savi-comms-teams.zip downloads." 5.  "On the same card, click Turn Teams on.  The card then reads Teams is on."

Check:  after step 3 the card says the bot is ready and shows the download button.  On the server, curl -s -o /dev/null -w "%{http_code}\n" https://<EDGE_NAME>/api/teams/messages now prints 405, not 502:  the Teams port is open.

If the card refuses in step 3, it shows the by-hand steps in the Azure portal.  Read the refusal to the administrator and stop; do not work through the by-hand steps unless they ask you to.

6.1 Add the app to the company's Teams

The administrator uploads the package to the company's own app catalog, so it is available to the people they choose.  Say:

1.  "Open the Teams admin center at admin.teams.microsoft.com and sign in." 2.  "Go to Teams apps, then Manage apps, and click Upload new app.  Choose savi-comms-teams.zip." 3.  "Open Teams.  Click Apps, then Built for your org, find Savi Comms, and click Add."

Check:  a chat with Savi Comms opens in Teams.  If Savi Comms is not under Built for your org, the catalog can take a few minutes, and up to a few hours, to reach the Teams app.  The Teams web app at teams.microsoft.com usually shows it first.

6.2 Say something to it

Say:  "In the Savi Comms chat in Teams, write to one of your agents, such as <AIR_AGENT>, and ask it anything."

Check:  the agent answers in the chat.  The first answer can take a minute while the agent starts.

6.3 Slack and Google Chat, if the company uses them [Administrator]

Savi Comms can also carry the agents into Slack or Google Chat, each in a direct message.  Each has its own card in the console, under Settings, then Apps and connections, and each card shows its steps in full.  Both need the public address from Part 6 step 1; until it is set, the card says so and shows the same address box as the Teams card.

Slack.  The administrator needs to be able to add apps to the company's Slack workspace.

1.  Open api.slack.com/apps in a browser signed in to the company's Slack workspace. 2.  Under Your App Configuration Tokens, click Generate Token, pick the workspace, and click Generate. 3.  Copy the access token it shows.  On the Slack card, paste it and click Make the Slack app.  The token lasts twelve hours; if Slack refuses it, generate a new one. 4.  Click Open the allow page in your browser.  Slack's page names the workspace and shows Allow.  Click Allow.  If the Slack app opens instead, copy the address the card shows into the browser's address bar and click Allow there. 5.  The card switches to connected by itself.  Click Turn Slack on.

Google Chat.  The administrator needs to be able to manage a Google Cloud project for the company and see the Google Admin console.

1.  Open console.cloud.google.com and pick or create the project Chat will use. 2.  Under APIs and services, enable the Google Chat API. 3.  Under IAM and admin, then Service accounts, create a service account.  Under its Keys, click Add key, Create new key, JSON, and keep the file it downloads. 4.  Back under the Google Chat API, open Configuration.  Give the app a name, turn on Receive one-to-one messages, and turn off Join spaces and group conversations. 5.  Under Connection settings, choose HTTP endpoint URL and enter the address the card shows.  Set Authentication Audience to Project Number. 6.  Under Visibility, make the app available to the people who will use it. 7.  On the Google Chat card, enter the project number (on the project's dashboard), the Workspace customer id (in the Google Admin console, under Account, then Account settings) and the contents of the key file, and click Connect Google Chat.  Then click Turn Google Chat on.

Check:  in Slack or Google Chat, write to one of the agents.  It answers in the same direct message.  The first answer can take a minute while the agent starts.

Part 7.  Hand over [Assistant]

Tell the administrator these four things, then the install is done:

1.  "The console is at https://<EDGE_NAME>/console.  Sign in with your company account." 2.  "Back up the folder ~/savi-comms-store on the server, including the file root.key inside it.  Without root.key a backup cannot be opened, and with it anyone can read every secret in the store, so keep the backups somewhere only you control." 3.  "The company sign-in secret stops working on <the date from Part 4>, and Home warns you as that day comes.  The Claude sign-in lasts a year from today; when it runs out, sign the installation in again from the Claude card on Home." 4.  "Decisions your agents need from you arrive as cards in Teams, and on the console's Approvals page."

Then tell them one more thing, in these words:  "Everyone who signs in to this server, people and assistants alike, uses one account and one key today, so the server's own log cannot say which of them did something.  Part 9 of the install guide says what that costs and how to give each one an account of their own."

Part 8.  Upgrading to a newer version [Assistant]

An upgrade replaces the program.  It never touches the two folders that hold the company's own data, ~/savi-comms-store and ~/agent-workspaces, and every step below is written so that stays true.

There are two ways to take a new version, and which one is available depends on the build the administrator was sent.

8.1 Which path this build can take

SAVI_COMMS_ROOT=~/savi-comms-store sh -c '. ~/savi-comms/scripts/broker_address.sh && broker_curl -s "$(broker_url)/health"'

The reply names the running version, and it needs no sign-in. /console/status answers 401 without a session, so /health is the address to use for every version check in this part.

A build carrying a release feed and a trust record can update itself from the console:  the administrator clicks Update now on Home and the server does the rest.  That is Part 8.2.  A build sent as a plain tarball with no feed cannot, and the upgrade is done by hand on the server.  That is Part 8.3.

If you did not build the file yourself and do not know which it is, use 8.3.  It works either way.

8.2 The console path, when the build has a release feed

The administrator opens Home, clicks Update now, and watches it.  The server unpacks the new version beside the running one, moves a pointer named current, stops the agents cleanly, restarts, and checks its own health.  When that check fails it moves the pointer back by itself and the screen says so.  The store and the workspaces are never written over, because the new version never lands on top of the old one.

Nothing on the server has to be stopped or renamed by hand on this path.

Two checks guard it.  Before the pointer moves, the update refuses a release that has no build for the server's Python, and the screen names the Python it needs; nothing on the server changes.  After the restart, the new version has 90 seconds, or five starts, to confirm it started.  If it never does, even when it stops before it can write a word, the update guard moves the pointer back and the old version starts again.  The guard is a short readable script, ~/savi-comms-store/releases/update-guard.sh, that the service manager runs before every start, so it does not depend on the new version working.  Home then says the update did not start and that the server went back, and that version is not offered again.

A server installed before 2026-10-12 takes the new service files once.  The start limit and the update guard live in the service files, which an update does not rewrite.  Run the installer from the release the server now runs, once:

bash ~/savi-comms-store/releases/current/scripts/install_linux_service.sh \
    --store ~/savi-comms-store --workspaces ~/agent-workspaces

Check: grep -c update-guard ~/.config/systemd/user/savi-comms.service prints 1, systemctl --user is-active savi-comms prints active, and /health names the same version as before.

8.3 The tarball path, by hand on the server

Every command here runs as the administrator's own user on the server, <ADMIN_USER>, over SSH.  None of them takes sudo.  The service is a systemd user service, so sudo systemctl stop savi-comms answers Unit savi-comms.service not loaded with exit code 5, which reads like "already stopped" and is not.  The installer in step 6 refuses to run as root for the same reason.

The service runs the release that ~/savi-comms-store/releases/current points at, never a folder you unpacked (Part 2.5).  An upgrade unpacks the new version beside it and runs that version's own installer, which copies it to ~/savi-comms-store/releases/<VERSION>, moves current to it and restarts the service.  The release that ran before stays in its own folder under releases, which is what Part 8.4 goes back to.

Copy the new savi-comms-<VERSION>.tar.gz and savi-comms-<VERSION>.tar.gz.sha256 to the server's home folder first, the same way Part 2.2 did it:

scp savi-comms-<VERSION>.tar.gz savi-comms-<VERSION>.tar.gz.sha256 <ADMIN_USER>@<SERVER_NAME>:~/

<VERSION> is read off the file you were sent, the way Part 2.2 explains.

cd ~
# 1.  Read which layout this server runs.  Go on only when it prints
#     "layout:  releases/current".  grep -q '^ExecStart=.*/savi-comms-store/releases/current/main\.py$' ~/.config/systemd/user/savi-comms.service && echo "layout:  releases/current" || echo "STOP:  the service does not run releases/current.  Read the paragraph below before going on."

# 2.  Verify the file before anything is unpacked.  sha256sum -c savi-comms-<VERSION>.tar.gz.sha256

# 3.  Write down the running version and the folder it runs from.  The last
#     part of the second line is <PREVIOUS>, the folder Part 8.4 goes back to.
#     If the version already names <VERSION>, stop here:  there is nothing
#     to install.  SAVI_COMMS_ROOT=~/savi-comms-store sh -c '. ~/savi-comms-store/releases/current/scripts/broker_address.sh && broker_curl -s "$(broker_url)/health"'
readlink ~/savi-comms-store/releases/current

# 4.  Unpack beside the running release.  This writes only
#     ~/savi-comms-<VERSION>, and nothing that runs reads it yet.  tar -xzf savi-comms-<VERSION>.tar.gz

# 5.  Read the unit's database identity line, if it has one.  grep '^Environment=SAVI_COMMS_DATABASE_IDENTITY_CLIENT_ID=' ~/.config/systemd/user/savi-comms.service

# 6.  Run the new release's own installer.  It copies the release into
#     ~/savi-comms-store/releases/<VERSION>, moves releases/current to it,
#     brings ~/savi-comms-venv up to the new version, re-renders the unit
#     and restarts the service.  bash ~/savi-comms-<VERSION>/scripts/install_linux_service.sh \
    --store ~/savi-comms-store --workspaces ~/agent-workspaces

# 7.  Read it back.  readlink ~/savi-comms-store/releases/current
systemctl --user is-active savi-comms
SAVI_COMMS_ROOT=~/savi-comms-store sh -c '. ~/savi-comms-store/releases/current/scripts/broker_address.sh && broker_curl -s "$(broker_url)/health"'

If step 1 printed STOP, do not go on.  The service runs ~/savi-comms rather than releases/current, so the server was installed before the release folders existed, and an upgrade run here would lay the new version down while the service kept running the old one.  Move it to the release folders first, with the version it runs now, then run step 1 again:

bash ~/savi-comms/scripts/install_linux_service.sh --store ~/savi-comms-store --workspaces ~/agent-workspaces

That restarts the same version from ~/savi-comms-store/releases/<the running version>, and /health names the same version as before.  From then on this section and 8.4 apply.

Step 5's line goes into step 6.  When step 5 prints a line, the server carries more than one managed identity (Part 2.5), and step 6 has to name the same one, or the re-rendered unit drops it:  add --database-identity <the client id after the => to step 6.  When step 5 prints nothing, run step 6 as written.  The installer keeps server.conf, your own drop-in, as it is.

Check:  step 7's readlink names ~/savi-comms-store/releases/<VERSION>, is-active prints active, and /health names the new <VERSION> with "status":"ok" and "secrets_ok":true.  Then open https://<EDGE_NAME>/console and sign in, because a page that loads is the only proof the whole path works.

On a server with workspace access on, step 6 is followed in the same sitting by Part 8.6's --upgrade, pointed at the folder step 4 unpacked.

Confirm the data was untouched by the unpacking.  Before step 4 and again after it, before step 6:

stat -c '%y %n' ~/savi-comms-store/index.sqlite ~/agent-workspaces

Both times print the same two timestamps.  Stop if either timestamp changed:  unpacking touches nothing of the company's.

After step 6, index.sqlite may change once, and the log says why.  A release that changes the shape of the data brings index.sqlite up to date on its first start, one numbered change at a time, and adds to what is there without removing anything.  Each change it applied is a line in the service's log:

journalctl --user -u savi-comms --since "10 minutes ago" | grep "migration "

A line reading migration 0002_installation_tenant ran is the service doing its job.  If index.sqlite changed and no such line appears, stop, leave ~/savi-comms-store/releases/<PREVIOUS> where it is, and tell the vendor what you ran.

Do not check the store folder's own timestamp.  It moves on every start and a correct upgrade changes it.  The service rewrites secrets.enc and secret-names.json inside that folder when it comes up, and replacing a file changes the folder that holds it, so the folder's timestamp says the service started rather than saying anything about the company's data. index.sqlite is the company's data:  every message, agent, rule and audit entry is in it.  Only the new version's first start writes to it, and only to bring it up to date, with a log line for each change.

After a day on the new version, ~/savi-comms-<VERSION> and the tarball in the home folder can be removed; the service never runs them.  Keep ~/savi-comms-store/releases/<PREVIOUS>, since 8.4 goes back to it.

8.4 Going back

The previous release is still on disk at ~/savi-comms-store/releases/<PREVIOUS>, the folder step 3 of 8.3 wrote down.  Going back is running that release's own installer from its own folder:  it copies nothing, points releases/current back at it, re-renders that release's own unit and restarts.

ls ~/savi-comms-store/releases/<PREVIOUS>/main.py
bash ~/savi-comms-store/releases/<PREVIOUS>/scripts/install_linux_service.sh \
    --store ~/savi-comms-store --workspaces ~/agent-workspaces
readlink ~/savi-comms-store/releases/current

Add the same --database-identity step 6 of 8.3 took, if it took one.  The ls has to name the file; when it answers No such file or directory, stop, because there is no earlier release on disk to go back to.

Check: readlink names ~/savi-comms-store/releases/<PREVIOUS>, and the health check from step 7 of 8.3 names the old version again.

Keep ~/savi-comms-store/releases/<VERSION> too, since 8.5 may send you back to it.  Remove a release folder only once nothing points at it and a newer one has served the administrator for a day; the store and the workspaces are not in it.

8.5 What an upgrade preserves, said plainly

The store keeps every message, agent, secret name, rule and audit entry, and its database is built so a newer version only ever adds tables and columns.  Reading is by column name, so an older version reading a wider table is fine.  Going back one version is safe for that reason.

The store also carries a format number of its own, a whole number in the file ~/savi-comms-store/store-format.  A release raises it only when it writes something in the store that an older release cannot read, and most releases leave it alone.  Each version refuses to start on a store with a higher number than it understands.  So when going back is not safe, 8.4 does not half work:  the older version stops at start, and the service's log says why:

journalctl --user -u savi-comms --since "10 minutes ago" | grep "REFUSING TO START"

A line naming the store's format and the highest one this version understands means the version you just installed changed the store's format, and the way back is closed.  Put the newer version back by running its own installer from its folder under releases; the store is left as it wrote it:

bash ~/savi-comms-store/releases/<VERSION>/scripts/install_linux_service.sh \
    --store ~/savi-comms-store --workspaces ~/agent-workspaces

Then run the health check from Part 2.5 and confirm it names the newer version.  Do not edit store-format by hand to get past the refusal; the number is there because the older version cannot read what the newer one wrote.

One limit worth knowing.  Going back more than one version has not been tested and is not something to try on a server the company is using.

8.6 With workspace access on:  the gate dispatcher, and the lockout an upgrade can cause

This applies only to a server where workspace access is on, so that each agent works as its own account on the server.  On such a server the installer places a gate dispatcher outside the program folder, owned by root, and every agent session runs its gate through it.  When a release changes that dispatcher, the program cannot replace it, since only root can; the installer's upgrade does.  Until it does, the broker holds the agents' wakes rather than run them through a dispatcher it did not ship.

LOCKOUT.  A RELEASE THAT CHANGES THE DISPATCHER BODY BEFORE sudo --upgrade RUNS HOLDS EVERY WAKE OF EVERY AFFECTED AGENT, AND NO AGENT CAN CLEAR ITS OWN HOLD.  THE WAY BACK IS A ROOT SHELL (THE BREAK-GLASS ACCOUNT, THE AZURE SERIAL CONSOLE OR az vm run-command invoke), BRINGING THE DISPATCHER FORWARD WITH THE INSTALLER'S --upgrade:

sudo bash <unpacked release>/scripts/install_server_access.sh --upgrade

OR RETURNING EVERY SESSION TO THE STORE'S OWN COPIES:

sudo rm -rf /etc/savi-comms/gate/agents && sudo rm -f /etc/savi-comms/gate/code/gate-dispatch.sh

REMOVING /etc/savi-comms/gate/agents ALONE DOES NOT LIFT THIS HOLD.  The hold reads the dispatcher itself whenever it is there, so the full return to the store's own copies takes both removals.  Run the upgrade command in the same sitting as every upgrade of the program on a server with workspace access, and the hold never appears.

Removing the dispatcher alone, sudo rm -f /etc/savi-comms/gate/code/gate-dispatch.sh, does lift the hold.  Every session then runs the store's own dispatcher, which the program keeps current, against the store's own copies of each gate.  Those stay readable to the agents' accounts only while the interim access list stands (until 2026-11-08), so the installer's --upgrade is the way back to use.

What you see while the hold stands:  the agents do not wake.  Their mail waits unread and nothing is lost.  The person who looks after each agent, or the administrator when nobody does, gets one message a day titled "Wakes held:  the server's gate dispatcher is behind this release", naming the release that placed the dispatcher and the two commands above.  Restarting Savi Comms does not lift it.  A person's own Claude session in an agent's folder is not held:  until the upgrade it runs the earlier release's dispatcher, as it did before the program was upgraded.

How you know it worked:  the agent's next wake runs instead of being held.

Part 9.  One account on the server, and how to separate it [Administrator decides, Assistant runs]

Nothing in this part is needed for the install to work.  It is the administrator's decision, and nothing here runs until they have made it.

9.1 What the install leaves today

Part 1.1 creates one Linux account, <ADMIN_USER>, with one SSH key:  the key of whoever ran az vm create.  Everything on the server then happens as that account:

Woken agent sessions run without Claude Code permission prompts.  Every tool call is checked by the Savi Comms gate, which refuses by policy.

9.2 Why that is a limit

The server's own records, /var/log/auth.log and the systemd journal, name an account and an address.  With one account and one key they can say what was done and when, and cannot say who did it.  Every session an assistant runs from the same machine arrives from the same address as well, so the address does not tell them apart either.

Two more things follow from sharing one key.  Taking one person's access away means changing the key for everyone who holds it.  And an agent session running as the service's own account can read the files the service keeps to itself, the agents' tokens and the signing key among them, because to the operating system they are the same account.

9.2a What the administrator's token alone may do

The administrator's token is a file, and on an install with one account every agent session can read it.  So the token is not enough to change anything once the administrator has a way to prove he is here.

Reads are a detection control only.  Every read on the token leaves an owner.read entry with the session marks the command saw.  It refuses nothing, a caller writes the marks, and it says nothing about earlier reads.  It catches a cooperating session.  Never cite it as meeting a prevention requirement.

The limit.  None of this stops a program that can write the store or the broker's code, since it could add a passkey of its own.  Only 9.3 separates that.

9.3 Agent sessions under an account of their own

Savi Comms can start every agent session as a second Linux account, so the operating system refuses an agent a file only the service may read.  It is off until that account exists, and it is one account shared by every agent:  it separates the agents from the service, and it does not separate one agent from another.  Part 9.5, workspace access, gives each agent an account of its own instead.

The service cannot create an account from its own, so the steps run once with sudo.  Print them for this server, naming the account the agents will run as (savi-agent here) and the service's own account:

cd ~/savi-comms
SAVI_COMMS_ROOT=~/savi-comms-store ~/savi-comms-venv/bin/python -c "from services.session_identity import install_plan; print(chr(10).join(install_plan('savi-agent', '<ADMIN_USER>')))"

Check:  it prints seven lines, the first beginning create the account 'savi-agent' and the last beginning set SAVI_AGENT_RUN_AS=savi-agent.  Show the administrator the seven lines before running any of them.  The last one is a line in the drop-in file Part 2.5 wrote, ~/.config/systemd/user/savi-comms.service.d/server.conf, followed by systemctl --user daemon-reload and systemctl --user restart savi-comms.

Check after the restart:  send any agent a message from the console and watch it answer.  While the switch to the agent account does not work, the service holds every wake rather than running it as its own account, writes not woken with the reason to its log, and sends one message headed Wakes held: the agent account cannot be used, once a day:

journalctl --user -u savi-comms --since "10 minutes ago" | grep "not woken"

Nothing printed means no wake was held.

9.4 People and assistants under accounts of their own

This is the server's own business rather than the product's, and it is what makes the log say who.  For each person or assistant who signs in, the administrator makes an account with that one's own key, and nobody signs in as <ADMIN_USER> afterward. <NAME> is the account's name, and <THEIR_PUBLIC_KEY> is the one line of their .pub file, which they send the administrator:

sudo adduser --disabled-password --gecos "" <NAME>
sudo install -d -m 700 -o <NAME> -g <NAME> /home/<NAME>/.ssh
echo "<THEIR_PUBLIC_KEY>" | sudo tee /home/<NAME>/.ssh/authorized_keys >/dev/null
sudo chown <NAME>:<NAME> /home/<NAME>/.ssh/authorized_keys
sudo chmod 600 /home/<NAME>/.ssh/authorized_keys

Give sudo only to the accounts that install or upgrade: sudo usermod -aG sudo <NAME>.  They then work on the service through sudo, which records the account that asked:

sudo -u <ADMIN_USER> XDG_RUNTIME_DIR=/run/user/$(id -u <ADMIN_USER>) systemctl --user restart savi-comms

Check:  the new account signs in with ssh <NAME>@<SERVER_NAME>, and after it runs a sudo command, sudo grep <NAME> /var/log/auth.log | tail -1 names it.  When every account works, the administrator removes the original key from /home/<ADMIN_USER>/.ssh/authorized_keys, so that no one is left signing in as the shared account.  Keep one working sudo account signed in while doing that, in case the removal locks out more than meant.

9.5 Workspace access

Workspace access gives each agent its own account on the server, lets the people the administrator assigns work in an agent's folder over SSH with their own Microsoft sign-in, and keeps the agents' keys out of reach.  It is set up once, as root, with one command.  The console shows that command on People, under Set up server access, with every value already filled in for this server; the administrator types the break-glass account and presses Copy.  The same command, by hand, one step to a line:

sudo apt-get install -y acl nftables curl
sudo install -d -o root -g root -m 0755 /opt/savi-comms-setup/<VERSION>
sudo cp -a <RELEASE>/. /opt/savi-comms-setup/<VERSION>/
sudo chown -R root:root /opt/savi-comms-setup/<VERSION>
sudo chmod -R go-w /opt/savi-comms-setup/<VERSION>
sudo env -C /opt/savi-comms-setup/<VERSION> sha256sum -c --quiet MANIFEST.sha256
sudo bash /opt/savi-comms-setup/<VERSION>/scripts/setup_server_access.sh --admin <ADMIN_USER> --from-store <STORE> \
    --claude-path <CLAUDE> --workspace-root <WORKSPACES> --allowed-domains <DOMAINS> --break-glass <BREAK_GLASS>

The first line installs the packages setup needs: acl for setfacl, nftables for the instance-endpoint fence, and curl for its probes.  These are the Debian and Ubuntu package names; the account installer refuses, naming them, when one is missing.  The next five lines make a copy of the running release that only root can change, and check every file in it against the release's own MANIFEST.sha256, which prints nothing when every file matches.  Both installers trust a release folder only when root, or the account running sudo, owns it, and the running release belongs to the service's account, which has no sudo once the service has moved.  Setup then runs from the copy with plain sudo.

It moves the service to its own account, savi-comms, with install_server_account.sh, then installs workspace access with install_server_access.sh.  It stops at the first step that refuses, in that installer's own words; running the same command again after fixing what it names starts at the step that stopped.  Run over a finished setup, it refuses and changes nothing.

Its last step moves each agent's safety check, the gate, out of the program's data folder to /etc/savi-comms/gate, owned by root, and turns on the root service that keeps it there.  Nothing is waiting on you for this:  once Turn on has made an agent's account, Savi Comms writes which account and which folder belong to which agent within a minute, and the root service carries that and the agent's gate across.  Until it has, a Claude session in an agent's folder is refused rather than run unchecked.  A server set up by an earlier release moves its gate with its own step, sudo bash <unpacked release>/scripts/install_server_access.sh --publish-gate.

Before the move, Savi Comms and every agent session ran as <ADMIN_USER>, so the files they made in the workspaces (state/, outputs/, .claude/ and the rest) belong to that account.  Setup records those files once, in /etc/savi-comms/former-broker.json, and the workspace walker gives exactly those to savi-comms, so each agent and the people who look after it can write them.  Anything <ADMIN_USER> makes in a workspace afterwards is left as it is and listed on the walker's card.  A server set up by an earlier release gets the same record from install_server_access.sh --upgrade.  Setup also starts savi-access-watch.service, which opens a folder an agent makes to the people who look after that agent as soon as it is made, rather than at the walker's next pass.

Check:  its last line starts with setup_server_access: done.  Refresh People in the console:  the setup card is replaced by Server access.  Press See who would get which folder, then Turn on.

Setup also writes /usr/local/bin/comms, the comms command every account on the server finds.  The service's own account runs the installed release with it.  Everybody else runs the small client setup places in /usr/local/libexec/savi-comms, which asks the service, so a person who looks after an agent can run comms inbox in that agent's folder, in a plain shell or in their own Claude session there, and gets the agent's mail.

The store also holds a terminal launcher, <STORE>/bin/savi-claude, which Savi Comms places each time the service starts. savi-claude <agent> starts Claude Code in that agent's folder, working as the agent, with the agent's connector secrets handed over from the encrypted store and a Claude sign-in folder of the session's own, so it never changes the installation's sign-in. docs/QUICKSTART.md, "Working as an agent from a terminal", describes what it prints and which sign-in the session runs on.  It runs only as the account the service runs under: <ADMIN_USER> before this setup, savi-comms after it.  So once the service has moved, a person who looks after an agent does not use it; they run their own Claude session in the agent's folder, as above.

Each agent's page in the console has a Terminal tab, for the administrator, with everything the agent's sessions said and did, each session headed Woken session or Terminal session. docs/QUICKSTART.md, "Working as an agent from a terminal", describes the tab and the line on the agent's Home card that names the last thing it ran.  The tab reads terminal sessions from the Claude projects folder of the account the service runs under, ~/.claude/projects unless the installation setting SAVI_COMMS_TERMINAL_PROJECTS names another, so a session a person runs under their own account is not in it.

Check, once a person has been given an agent:  that person signs in over SSH, changes into the agent's folder, and runs comms inbox.  It prints the agent's inbox.  The account <ADMIN_USER> is not one of the people, so it cannot open an agent's folder even though the folders sit in its home:  the administrator works in them with their own Microsoft sign-in, and the break-glass account stays the way in when that fails.

Part 10.  Moving a server already installed on port 80 to https [Assistant, with the Administrator]

A server installed from an earlier copy of this guide has Front Door forwarding to it over plain http on port 80:  the network security group rule savi-comms-frontdoor-http, a route with --forwarding-protocol HttpOnly, and a Caddyfile beginning :80 {.  Session cookies, tokens and message content cross that hop unencrypted.  This part moves it to the arrangement Parts 1 and 3 now describe, in an order that never stops the service:  the server learns to answer on 443 with its certificate first and is checked there, Front Door is switched next and checked through the public address, and port 80 closes last.  Each step has its rollback, and nothing in a step is undone by the step after it.

Collect <DNS_ZONE>, <DNS_IN_AZURE>, <DNS_GROUP> and <ORIGIN_NAME> as Part 0 describes, and read <FD_GROUP>, <FD_PROFILE>, <FD_ENDPOINT> and <FDID> from the server's earlier install.  Run Part 1, step 1.2 again so NSG_NAME, NIC_ID and SERVER_PRIVATE_IP are set.

10.1 The server's name and its permission

Run Part 1, step 1.4 exactly as written, with its check.  It changes nothing the running service uses.

Rollback, if it is ever wanted: az role assignment delete --assignee "$VM_PRINCIPAL" --scope "$ZONE_ID", then az role definition delete --name "Savi Comms certificate challenge (<ORIGIN_NAME>)", then az network dns zone delete -g <DNS_GROUP> -n <ORIGIN_NAME> --yes.  The identity itself can stay:  with no role it can do nothing.

10.2 Open 443 to Front Door, beside 80

az network nsg rule create -g <RESOURCE_GROUP> --nsg-name "$NSG_NAME" -n savi-comms-frontdoor-https \
    --priority 1020 --direction Inbound --access Allow --protocol Tcp \
    --source-address-prefixes AzureFrontDoor.Backend --source-port-ranges '*' \
    --destination-address-prefixes "$SERVER_PRIVATE_IP" --destination-port-ranges 443

Check:  Part 1, step 1.3's list shows both rules:  the old one, port 80, and this one, port 443.  If 1020 is taken, use 1040 and say so.

Rollback:

az network nsg rule delete -g <RESOURCE_GROUP> --nsg-name "$NSG_NAME" -n savi-comms-frontdoor-https

10.3 Caddy on 443 with its certificate, still answering on 80

On the server, keep a copy of the file that works today, then install the Caddy build:

sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.port80
cd ~/savi-comms
sudo sh scripts/install_caddy_azure_dns.sh install

If ~/savi-comms predates this change and has no scripts/install_caddy_azure_dns.sh, upgrade first (Part 8).  Check it as Part 3.1 says.

Transitional, the file for this step only:  the new https site, and the old :80 site left as it was, so Front Door keeps working while the certificate is obtained and checked.  Step 10.6 removes the :80 site.

sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
{
    auto_https disable_redirects
}

https://<ORIGIN_NAME> {
    tls {
        dns azure {
            subscription_id <DNS_SUBSCRIPTION_ID>
            resource_group_name <DNS_GROUP>
        }
    }
    route {
        respond /fd-probe 200
        @notours not header X-Azure-FDID <FDID>
        respond @notours 403
        reverse_proxy /api/teams/* 127.0.0.1:8431
        reverse_proxy 127.0.0.1:8421
    }
}

:80 {
    route {
        respond /fd-probe 200
        @notours not header X-Azure-FDID <FDID>
        respond @notours 403
        reverse_proxy /api/teams/* 127.0.0.1:8431
        reverse_proxy 127.0.0.1:8421
    }
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.both
sudo systemctl restart caddy

Check:  every check in Part 3.3 that runs on the server passes:  the certificate obtained, the 403, the "status":"ok", the 200. sudo ss -ltnp | grep caddy lists both :80 and :443 at this step, which is expected.  And the public address still answers, still through port 80: curl -s https://<EDGE_NAME>/health prints the "status":"ok" line.  Do not go on until all of them pass.

Rollback:

sudo cp /etc/caddy/Caddyfile.port80 /etc/caddy/Caddyfile
sudo update-alternatives --set caddy /usr/bin/caddy.default
sudo systemctl restart caddy

10.4 Front Door to https

Front Door can reach the server by more than one route.  A profile set up by hand often carries a second endpoint, a www route, or a second origin group pointing at the same server, and every one of them keeps forwarding over port 80 until it is moved too; 10.5 would then never read 0.  So first list, on every endpoint of the profile, each origin that names the server and each route that sends traffic to one, and keep the list in fd-hop.txt.  Every move and every rollback below reads that file, so run them from the same folder:

: > fd-hop.txt
for g in $(az afd origin-group list -g <FD_GROUP> --profile-name <FD_PROFILE> --query "[].name" -o tsv); do
    az afd origin list -g <FD_GROUP> --profile-name <FD_PROFILE> --origin-group-name "$g" \
        --query "[?hostName=='<SERVER_NAME>'].[name, originHostHeader]" -o tsv </dev/null |
    while read -r o header; do
        probe=$(az afd origin-group show -g <FD_GROUP> --profile-name <FD_PROFILE> -n "$g" \
            --query healthProbeSettings.probeProtocol -o tsv </dev/null)
        echo "origin $g $o $header ${probe:-none}" >> fd-hop.txt
    done
done
for e in $(az afd endpoint list -g <FD_GROUP> --profile-name <FD_PROFILE> --query "[].name" -o tsv); do
    az afd route list -g <FD_GROUP> --profile-name <FD_PROFILE> --endpoint-name "$e" \
        --query "[].[name, originGroup.id, forwardingProtocol]" -o tsv </dev/null |
    while read -r r gid fp; do
        g="${gid##*/}"
        if awk -v g="$g" '$1 == "origin" && tolower($2) == tolower(g) {f = 1} END {exit !f}' fd-hop.txt; then
            echo "route $e $r $g $fp" >> fd-hop.txt
        fi
    done
done
cat fd-hop.txt

Check:  the file has at least one origin line and one route line, and every address a person reaches the server by is served by a route line.  Each origin line ends with the origin group's probe protocol, or none; each route line ends with the protocol it forwards over today, which is what its rollback restores.  The group name is compared without regard to case, because Azure can return a group's id in a different case from its name.  No route line means the server's name did not match any origin:  stop and read <SERVER_NAME> again.

Point every origin that names the server at its new name and check its certificate.  The routes still forward over port 80 at this moment, and the :80 site answers any name, so nothing changes for anyone yet:

grep '^origin ' fd-hop.txt | while read -r _ g o header probe; do
    az afd origin update -g <FD_GROUP> --profile-name <FD_PROFILE> --origin-group-name "$g" -n "$o" \
        --host-name <ORIGIN_NAME> --origin-host-header <ORIGIN_NAME> --https-port 443 \
        --enforce-certificate-name-check true </dev/null
done

Check:

grep '^origin ' fd-hop.txt | while read -r _ g o header probe; do
    echo "$g $o:"
    az afd origin show -g <FD_GROUP> --profile-name <FD_PROFILE> --origin-group-name "$g" -n "$o" \
        --query "[hostName, originHostHeader, httpsPort, enforceCertificateNameCheck]" -o tsv </dev/null
done
curl -s https://<EDGE_NAME>/health

Each origin shows <ORIGIN_NAME> twice, 443 and True, and the last line is the "status":"ok" line.

Rollback, the origins, each back to the name and host header the list saved:

grep '^origin ' fd-hop.txt | while read -r _ g o header probe; do
    az afd origin update -g <FD_GROUP> --profile-name <FD_PROFILE> --origin-group-name "$g" -n "$o" \
        --host-name <SERVER_NAME> --origin-host-header "$header" </dev/null
done

Then switch every route:

grep '^route ' fd-hop.txt | while read -r _ e r g fp; do
    az afd route update -g <FD_GROUP> --profile-name <FD_PROFILE> --endpoint-name "$e" -n "$r" \
        --forwarding-protocol HttpsOnly </dev/null
done

In the same sitting, move to https the probe of every origin group that has one:

grep '^origin ' fd-hop.txt | awk '$5 != "none" {print $2}' | sort -u | while read -r g; do
    az afd origin-group update -g <FD_GROUP> --profile-name <FD_PROFILE> -n "$g" --probe-protocol Https </dev/null
done

Check, through the public address, from any computer other than the server:

grep '^route ' fd-hop.txt | while read -r _ e r g fp; do
    printf '%s %s ' "$e" "$r"
    az afd route show -g <FD_GROUP> --profile-name <FD_PROFILE> --endpoint-name "$e" -n "$r" \
        --query forwardingProtocol -o tsv </dev/null
done
curl -s https://<EDGE_NAME>/health

Every route prints HttpsOnly.  The last line is the "status":"ok" line; run the curl every minute for ten minutes, because Front Door's edges take that long to agree, and every answer must be "status":"ok".  A 502 or 504 means Front Door refused the certificate:  run the rollback below at once, then use the table at the end.  Then have the administrator open https://<EDGE_NAME>/console and sign in, because a page that loads is the only proof the whole path works.

Rollback, every route, each back to the protocol the list saved:

grep '^route ' fd-hop.txt | while read -r _ e r g fp; do
    az afd route update -g <FD_GROUP> --profile-name <FD_PROFILE> --endpoint-name "$e" -n "$r" \
        --forwarding-protocol "$fp" </dev/null
done

Rollback, the probes, if they were moved:

grep '^origin ' fd-hop.txt | awk '$5 != "none" {print $2, $5}' | sort -u | while read -r g p; do
    az afd origin-group update -g <FD_GROUP> --profile-name <FD_PROFILE> -n "$g" --probe-protocol "$p" </dev/null
done

10.5 Confirm nothing still arrives on 80

On the server, twice, ten minutes apart:

sudo ss -Htn state established '( sport = :80 )' | wc -l
sudo ss -Htn state established '( sport = :443 )' | wc -l

Check:  the first prints 0 both times, and the second prints more than 0.  A connection still open on 80 after ten minutes means some edge still forwards over http, or a route reaches the server that fd-hop.txt does not list.  Keep the first list, because its saved protocols are what the rollbacks restore:  cp fd-hop.txt fd-hop.first.txt, run 10.4's list again, compare the two files, move any route the first one missed, and read again ten minutes later.  Do not close port 80 while it is above 0.

10.6 Close port 80

az network nsg rule delete -g <RESOURCE_GROUP> --nsg-name "$NSG_NAME" -n savi-comms-frontdoor-http

Check:  Part 1, step 1.3's list no longer shows a port 80 rule from AzureFrontDoor.Backend, and curl -s https://<EDGE_NAME>/health still prints the "status":"ok" line.

Rollback:

az network nsg rule create -g <RESOURCE_GROUP> --nsg-name "$NSG_NAME" -n savi-comms-frontdoor-http \
    --priority 1010 --direction Inbound --access Allow --protocol Tcp \
    --source-address-prefixes AzureFrontDoor.Backend --source-port-ranges '*' \
    --destination-address-prefixes "$SERVER_PRIVATE_IP" --destination-port-ranges 80

Then take the :80 site out of Caddy.  This is the file Part 3.2 writes, with its values:

sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
{
    auto_https disable_redirects
}

https://<ORIGIN_NAME> {
    tls {
        dns azure {
            subscription_id <DNS_SUBSCRIPTION_ID>
            resource_group_name <DNS_GROUP>
        }
    }
    route {
        respond /fd-probe 200
        @notours not header X-Azure-FDID <FDID>
        respond @notours 403
        reverse_proxy /api/teams/* 127.0.0.1:8431
        reverse_proxy 127.0.0.1:8421
    }
}
EOF
sudo systemctl reload caddy

Check: sudo ss -ltnp | grep caddy lists :443 and no :80, every Part 3.3 check passes, and curl -s https://<EDGE_NAME>/health from any computer prints the "status":"ok" line.

Rollback:

sudo cp /etc/caddy/Caddyfile.both /etc/caddy/Caddyfile
sudo systemctl reload caddy

Keep /etc/caddy/Caddyfile.port80 and /etc/caddy/Caddyfile.both for a week, then remove them.  From here the server is the same as one installed from Parts 1 and 3, and Part 3.4 is how to read its certificate.

An agent that writes code [Administrator]

An agent you add cannot use git, and that is on purpose:  most agents never touch a repository.  When an agent's work is code, you turn that on for it in the console.

1.  Open the console, then Agents, and click Open beside the agent. 2.  Click the Configuration tab. 3.  In the Writing code card, tick This agent writes code.  The console asks you to confirm it is you, the same way it does for any change to an agent.

How you know it worked:  the card says the agent writes code now, and the agent's next git command runs.  Nothing needs restarting.

That one switch does three things together.  The agent may use git in its own repositories.  It is given the tier that developers hold, unless you had already given it a higher one.  And every push or deploy it makes waits for your yes on the Desk, so nothing it writes reaches a repository without you.

The card then shows the next step:  give the agent a GitHub App of its own, so its work on GitHub is recorded as its own and never as yours.  The guide that came with Savi Comms, docs/GITHUB-AGENT-IDENTITY.md, walks you through it, and the card shows the one command its last step runs, with the agent's name already filled in.

To undo it, untick the switch.  Git closes to the agent again, its tier goes back to what it was before, and it leaves the push gate.

Only you, the owner, can change this switch.  An agent that asks for git is asking you to make this change.

Closing a Desk decision with a commit.  When an agent's commit carries out a decision the administrator made on the Desk, the agent writes Executes: <ask id> among the commit's trailer lines, and Savi Comms closes the ask once that commit is on the repository's main branch.  Savi Comms looks in every git repository directly inside ~/Developer, at origin/main as that folder last fetched it; it does not fetch for you.  If the repositories are somewhere else, name their folder with the installation setting SAVI_COMMS_CODE_DIR, or list them with SAVI_COMMS_WORK_REPOS, as a JSON list of paths.  An ask whose commit is not seen stays open.  comms desk-executed <ask id> records an ask carried out only when Savi Comms can see the work itself: that commit on origin/main, the ask's grant used or closed, a release naming it, or, for a step only a person can take, that person's own message.  A sentence or a commit number typed by an agent is not enough, so fetch the repository and the ask closes on its own.

An agent you let read a repository's history without writing code may run a short list of git commands that only read, such as git log and git status.  You choose that list under Settings, then Agents and models, then Reading code history.  Only commands that read are offered, so nothing on it can change a repository.

When the agents wake [Administrator]

An agent costs nothing while it waits, and Savi Comms starts a session for it only when there is something to do.  Every message's sender sees which of these applied: comms send prints one line saying the message wakes the recipient now, waits for the digest, or is held, and why.

Mail.

A message is worth a session when it is urgent, needs a reply, asks for work, comes from the administrator, or carries a Desk decision, or when the recipient's task file holds work that is working or queued.  A message that is none of these starts no session, at the digest either.  It stays unread, and the agent reads it at its next wake.

A message an agent flags --requires-human goes to the administrator instead of waking anybody.  A message from an agent is also held, and sent to the administrator instead, when its conversation has passed 10 hand-offs between agents or 12 messages, or has woken agents 16 times.  A person's own messages are never held this way.  To reach the agent again, start a new conversation.

A terminal.  While a person works in an agent's folder from a terminal (Part 9.5), Savi Comms holds that agent's woken sessions until 5 minutes after the terminal was last used, so two sessions of one agent never work at once.  The installation setting SAVI_INTERACTIVE_WINDOW_SECONDS changes the 5 minutes, up to an hour.

Work an agent starts on its own.  By default agents start only when mail arrives.  In the console, Settings has a group called Working on their own:  a switch, Agents start work on their own; the days and hours; and Times a day agents may start on their own, across all agents, which is 0 when you install.  Set that number above 0, and during those days and hours (weekdays, 08:00 to 18:00, when you install) Savi Comms starts agents whose task files hold work, first the one the most other agents' work is waiting behind, at most 3 times a day for any one agent.

Those starts happen only while the administrator is present.  Savi Comms counts the administrator present for 90 minutes after they last used the console or the Decision Desk, ran comms as themselves, or used any agent's terminal.  If it cannot tell, it counts them present.  While they are away, the clocks that remind agents about unanswered mail, and escalate it to the administrator, pause as well.  Mail still wakes agents while they are away, and so does a schedule given to an agent on its Configuration tab in the console.

How long a session runs, and how many at once.  A woken session on Claude is stopped after 30 minutes.  At most two sessions on Claude run at once, across the whole installation rather than per agent; a wake that finds both in use waits for one to finish.  Both are installation settings: SAVI_COMMS_WAKE_TIMEOUT, in seconds, and SAVI_COMMS_CLAUDE_SLOTS.  On this server they go in the drop-in file Part 2.5 wrote, as Environment= lines, followed by systemctl --user daemon-reload and systemctl --user restart savi-comms.  More slots finish work sooner and use the company's Claude allowance faster.  Each agent runs one woken session at a time, and a wake that arrives while it works joins that session.  The installation setting SAVI_WAKE_SESSION_CAPS, a JSON object naming an agent and its number, lets that agent run up to 4 woken sessions at once, each still waiting for a slot like any other.  In the same drop-in file it is one line in single quotes, so systemd keeps the JSON's double quotes: Environment='SAVI_WAKE_SESSION_CAPS={"ada": 2}'.  The agent's Configuration tab says which applies.  A session on a model of the company's own is allowed 24 hours and waits for no slot.

When the server is offline

Savi Comms runs on the server itself, so most of it carries on without the internet.  What changes:

An Azure managed disk needs no passphrase, so the server starts by itself after a restart.  A server whose store is on LUKS waits for its passphrase at every start; docs/QUICKSTART.md, "A Linux machine that restarts by itself", sets out the ways to unlock it without a person at the keyboard, and what each one costs.

The store from the command line

scripts/set_secret.py, in the folder you unpacked, lists, sets and deletes what the encrypted secret store holds from this machine's own command line, and its list prints names only, never values. scripts/encrypt_store.py encrypts the mail, the record and the index of a store made before stored data was encrypted by default:  run it with --dry-run first, then --apply with Savi Comms stopped, and --rollback puts the store back.

When something is wrong

What you see What it means What to do
az vm open-port prints KeyError: 'networkSecurityGroup' The firewall is on the subnet, and that command only understands one on the network card Do not use that command.  Part 1, steps 1.2 and 1.3
az network nsg rule create says the priority is in use Another rule already holds 1010 Part 1, step 1.3, with 1030
The mv savi-comms-<VERSION> savi-comms step says no such file <VERSION> was guessed rather than read off the file Part 2, step 2.2. ls ~/savi-comms-* shows the real name
sha256sum does not print OK The file is damaged or not the vendor's Get the files again from the vendor.  Do not install it
The installer is refused on a second run It already ran once Run the health check from Part 2.5.  If the first run failed partway, move ~/savi-comms-store and ~/agent-workspaces aside and run Part 2.3 again
The log says Savi Comms will not start: the disk holding its store ... is not encrypted The broker could not confirm the store's disk is encrypted.  On Azure, the instance metadata service did not list a managed disk Check that the server's disks are Azure managed disks, and that the store is on one of them.  Show the administrator the full line
Part 2.5's grep -c LOOPBACK_TLS prints 1, or the edge answers 502 while the server's own health check passes The installer ran without SAVI_COMMS_PUBLIC_URL set, so the broker answers https on 8421 and Caddy's plain reverse_proxy 127.0.0.1:8421 cannot read it ~/savi-comms-venv/bin/python -c "import json,sys;p=sys.argv[1];d=json.load(open(p));d.pop('SAVI_COMMS_LOOPBACK_TLS',None);json.dump(d,open(p,'w'),indent=2)" ~/savi-comms-store/profile.json, then systemctl --user restart savi-comms.  The grep then prints 0
systemctl --user is-active savi-comms is not active The service did not start journalctl --user -u savi-comms --since "10 min ago" names the cause.  Show it to the administrator
journalctl --user -u savi-comms prints -- No entries -- over az vm run-command Run-command executes as root, so --user reads root's own user manager rather than the account the service runs under Read it as journalctl _UID=$(id -u <ACCOUNT>).  The service logs normally; the command was reading the wrong journal
https://<EDGE_NAME>/health fails from outside The route is still starting, port 443 is closed to Front Door, Caddy has no certificate yet, or Caddy is refusing Wait twenty minutes after Part 1, step 1.5.  Then Part 1, step 1.3, and Part 3.3's checks on the server
https://<EDGE_NAME>/health answers 403 Caddy does not recognise the Front Door id The <FDID> in /etc/caddy/Caddyfile differs from az afd profile show ... --query frontDoorId.  Correct it and reload Caddy
On the server, the first curl in Part 3.3, without the id, answers anything but 403 The id check is not running first Part 3.2:  the file needs the route block exactly as written
The edge answers 503, or Front Door shows the origin unhealthy, while the server answers on itself A health probe on the origin group is getting the 403, because probes carry no Front Door id Point the probe at /fd-probe over Https:  Part 1, step 1.5
The edge answers 502 or 504, and Part 3.3's checks on the server pass Front Door refused the server's certificate:  the origin's host name or host header is not <ORIGIN_NAME>, or the certificate does not chain to a public authority Part 1, step 1.5's az afd origin show check.  Both names must read <ORIGIN_NAME>.  Do not turn the name check off
journalctl -u caddy shows could not get certificate, with AuthorizationFailed or 403 The server's identity may not write the TXT record Part 1, step 1.4's role assignment check:  one row, scope ending dnszones/<ORIGIN_NAME>.  A role assigned in the last ten minutes may not have reached Azure's checks yet; wait and run sudo systemctl restart caddy
journalctl -u caddy shows could not get certificate, with ManagedIdentityCredential or no identity The server has no managed identity, or Caddy cannot reach Azure's identity endpoint Part 1, step 1.4's az vm identity assign
journalctl -u caddy shows could not get certificate, with NXDOMAIN, timeout or propagation The authority cannot find the server's zone:  the four NS records are missing at the parent Part 1, step 1.4. dig +short NS <ORIGIN_NAME> must print the zone's four name servers
journalctl -u caddy shows could not get certificate, with rateLimited The authority has refused too many attempts for this name Stop retrying.  Show the administrator the line; the limit lifts by itself, and the line says when
Part 3.4 shows a notAfter less than seven days away, or the edge starts answering 502 after weeks of working Renewal has been failing and the certificate is expiring or expired Read sudo journalctl -u caddy --since "3 days ago" --no-pager | grep -i certificate and use the rows above.  When the cause is fixed, sudo systemctl restart caddy obtains a new one at once
install_caddy_azure_dns.sh says REFUSED The download does not match the build the vendor pinned, usually because Caddy has released a newer version Stop and tell the vendor.  Nothing was installed and the server still runs the Caddy it had
az role assignment create says the role does not exist The role was defined seconds ago Wait a minute and run it again:  Part 1, step 1.4
sudo ss -ltnp | grep caddy lists :80 The Caddyfile has a site on port 80, or lacks auto_https disable_redirects Part 3.2.  Write the file again and restart Caddy
The return address in Part 5 starts with http:// or names localhost The public address setting is missing Part 2, step 2.5, then restart the service
Microsoft says the return address does not match The registration's address differs from the console's Part 4, the az ad app show check
Home says the Claude sign-in timed out, or that no sign-in was issued for the code The code did not match the sign-in waiting for it Part 5.3 again, from step 1, with one click and the whole code
The Teams address /api/teams/messages still answers 502 after the bot exists The service has not picked up the bot systemctl --user restart savi-comms, then check again
An agent does not answer in Teams The agent cannot start The console's Home shows why, in plain words
sudo systemctl stop savi-comms says Unit savi-comms.service not loaded, exit 5 The service is a user service; sudo looks in the wrong place, and this is not "already stopped" Part 8.3.  Run systemctl --user stop savi-comms as <ADMIN_USER>, without sudo
After an upgrade the service will not start, and the log names a path that does not exist releases/current points at a folder with no release in it Part 8.4. readlink ~/savi-comms-store/releases/current shows where it points
/health still names the old version after an upgrade The service runs ~/savi-comms rather than releases/current, or step 6 did not run Part 8.3, step 1.  If it prints STOP, move the server to the release folders first
~/savi-comms-store/index.sqlite has a newer timestamp after step 4 than before it Something wrote to the company's data before the new version started Stop, leave ~/savi-comms-store/releases/<PREVIOUS> where it is, and tell the vendor what you ran
index.sqlite has a newer timestamp after step 6, and the log shows migration lines The new version brought the data up to date on its first start Nothing is wrong.  Part 8.3
The store folder ~/savi-comms-store has a newer timestamp, but index.sqlite does not Nothing is wrong.  The service rewrites two secret files in that folder every time it starts Part 8.3.  Read index.sqlite, never the folder
Step 1 of Part 8.3 prints STOP: the service does not run releases/current The server was installed before the release folders existed Part 8.3.  Run the installer once from ~/savi-comms, then step 1 again
An agent's command is refused with DENIED by the git-access gate The agent is not set up to write code, so git is closed to it If its work is code, the administrator opens it in the console and turns on This agent writes code.  See "An agent that writes code"
An agent says it has no tier, or every action is refused Its tier was never set For an agent that writes code, turning on This agent writes code sets its tier.  See "An agent that writes code"
An agent's push is refused with DENIED by the push gate Every push and deploy by an agent that writes code waits for the administrator's yes The administrator answers the agent's ask on the Desk.  See "An agent that writes code"