This guide is for Savi Comms 2026.11.5. It is the same text the download carries at docs/INSTALL-SERVER.md.
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.
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.
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 |
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>"
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.
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>.
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.
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.
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.
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.
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:
* or Internet:
say it should be limited to their own address, and that anyone on the
internet can currently attempt to sign in to the server.10.0.0.0/16 or another company network: say which range,
and ask them to confirm they meant it. A company network is a
reasonable answer; it is their call, not yours.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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."
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.
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.
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.
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.
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.
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.
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.
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.
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:
<ADMIN_USER>;<ADMIN_USER> owns;<ADMIN_USER>
and that one key;Woken agent sessions run without Claude Code permission prompts. Every tool call is checked by the Savi Comms gate, which refuses by policy.
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.
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.
comms console --recovery asks for the passkey, and
the sign-in opens in the browser that used it. The link never comes
back to the terminal.owner.act_break_glass or
console.recovery_break_glass entry on the audit
record.comms console --recovery --lost-every-passkey is reachable
only on an installation where neither of those can work, which is what
keeps it out of reach of a program running under the account elsewhere.
Where it is reachable it is recorded every time and tells the
administrator's mailbox. Where it is not, the refusal names the way in
this installation has.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.
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.
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.
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.
<VERSION> is the running release's version, as
the console's status page shows it. * <RELEASE> is
the folder the running release was installed in, which holds
scripts/. * <ADMIN_USER> is the account
the service runs as now. * <STORE> is the store that
service uses (SAVI_COMMS_ROOT in its unit). *
<CLAUDE> is the Claude program sessions run. It has
to sit in a folder only root can change, such as
/opt/claude/claude; setup refuses one inside a home
folder. The console's command copies it there, before it runs setup,
when it is not. * <WORKSPACES> is the agents'
workspace folder (SAVI_AI_RESOURCES in the unit). *
<DOMAINS> is the company's email domains, separated
by commas, the ones company sign-in allows. *
<BREAK_GLASS> is a local account on the server that
signs in with an SSH key. It stays able to sign in if Microsoft sign-in
fails, so its name cannot hold an @, and it cannot be
root or savi-comms.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.
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.
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.
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
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
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
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.
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 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.
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.
--reply-required
or --asks-work wakes the recipient when it arrives.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.
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.
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.
| 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" |