Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ keys a code on the keypad, and the engine puts them in that room's Telnyx confer
app decides what a code means (a room someone made here), names callers it can, logs who
comes and goes, and streams that to the room page.

Our agents (riotcoder, cha0spunk, rootkey, CoinPay, Knox and Nora) are ElevenLabs voice agents built with
Our voice agents (riotcoder, cha0spunk, rootkey, CoinPay, Knox and Nora) are ElevenLabs voice agents built with
[@profullstack/voice-agent](https://github.com/profullstack/voice-agent) (`src/agents.ts`):
Claude Sonnet with real turn-taking, dialled straight into the room over SIP with X-Gc-*
headers that say which room, who asked and why. During the call they act through group.call's
Expand All @@ -52,11 +52,11 @@ through Telnyx and hears the room through Telnyx transcription. Anyone
else's agent can do the same from any telephony API, after naming itself with
`POST /api/v1/rooms/:code/expect`.

RiotCoder's phone fallback runs as the `riotcoder` Linux user. Its private worker key can claim
only RiotCoder invites and call tasks; the shared legacy worker skips RiotCoder's queue. The
installer keeps his Telnyx settings under `/home/riotcoder/.config/agent-phone/phone.env` and
installs the Off the Record podcast prompt in his own home. A trusted task requested in QryptChat
or during a call is handed to his full `agent-llm` runner and reported in the original chat.
Each configured agent has a separate `GROUPCALL_AGENT_WORKER_KEY_<AGENT>` and phone worker under
its own Linux account. A key can claim only that agent's calls and call tasks; the shared worker
skips every scoped account. Each worker hands tasks to its owner's QryptChat task runner, so
credentials and task state stay in that account. Trusted requests from QryptChat, a phone call,
or email run through the same full `agent-llm` profile and report to the originating chat.

Calling someone in: the room rings any US or Canadian number from the dial-in, and whoever
answers presses 1 to join. From the room page (**Call someone in**), the CLI
Expand All @@ -69,7 +69,7 @@ Contacts and inviting by name: each account keeps a contacts book of people and
with aliases for how speech recognition spells them ("chaos punk"). A contact can be
reached by one of our agent identities, a SIP address, a phone number, or email. Phone
contacts are called; email-only contacts receive the room name, dial-in number and code
by email, then call in themselves. Operators have all six fleet agents in their book
by email, then call in themselves. Operators have all seven fleet agents in their book
without saving them. On a call, a known caller (or a person the owner marked trusted) just
says **"invite riotcoder, cha0spunk and Kim"**: each name is matched against the room
owner's book, agents are queued, people are rung or emailed, and the room hears what
Expand Down
4 changes: 2 additions & 2 deletions bin/install.conf
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# No secrets here: settings go in $STATE_DIR/app.env, written from the logicsrc
# vault profullstack/group-call--prod (GROUPCALL_KEY, GROUPCALL_COINPAY_CLIENT_ID/SECRET,
# COINPAY_API_KEY, COINPAY_WEBHOOK_SECRET, TELNYX_API_KEY, TELNYX_PUBLIC_KEY,
# GROUPCALL_DIAL_IN, GROUPCALL_AGENT_WORKER_KEY, GROUPCALL_AGENT_WORKER_KEY_RIOTCODER (agent-scoped),
# GROUPCALL_DIAL_IN, GROUPCALL_AGENT_WORKER_KEY (legacy), GROUPCALL_AGENT_WORKER_KEY_<AGENT> (agent-scoped),
# GROUPCALL_AGENT_OPERATORS (CoinPay subs, never emails),
# GROUPCALL_AGENT_CALLER_IDS=riotcoder=+1...,cha0spunk=+1..., GROUPCALL_TRANSCRIBE,
# GROUPCALL_SMTP_HOST/PORT/USER/PASS, GROUPCALL_EMAIL_FROM,
Expand All @@ -20,4 +20,4 @@ PORT=8810
HEALTH_PATH=/health
DOMAINS=
ACCESS_LOG=off
AGENT_PHONE_USER=riotcoder
AGENT_PHONE_USERS="cha0spunk coinpay knox nora quill riotcoder rootkey"
181 changes: 111 additions & 70 deletions bin/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -277,99 +277,132 @@ phase_setup() {
install_agent_phone() {
[ "$APP" = group-call ] || return 0
[ -f "$SRC_DIR/ops/agent-phone.mjs" ] || return 0
local worker_user worker_home worker_group worker_node source_node source_node_version source_node_major source_node_minor phone_source phone_tmp worker_key worker_env_tmp
worker_user="${AGENT_PHONE_USER:-riotcoder}"
worker_home="$(getent passwd "$worker_user" | cut -d: -f6)"
[ -n "$worker_home" ] || die "AGENT_PHONE_USER '$worker_user' has no home directory"
worker_group="$(id -gn "$worker_user")"
if [ "$worker_user" = riotcoder ]; then
# Install a private Node runtime copy so RiotCoder never executes through
# Anthony's mise shims or depends on Anthony's home directory.
source_node="$(find /home/anthony/.local/share/mise/installs/node -maxdepth 4 -type f -name node -perm /111 2>/dev/null | sort -V | tail -n 1)"
[ -n "$source_node" ] || die "Node 22.18+ runtime not found for agent worker"
source_node_version="$("$source_node" --version 2>/dev/null || true)"
source_node_version="${source_node_version#v}"
source_node_major="${source_node_version%%.*}"
source_node_minor="${source_node_version#*.}"
source_node_minor="${source_node_minor%%.*}"
[[ "$source_node_major" =~ ^[0-9]+$ && "$source_node_minor" =~ ^[0-9]+$ ]] || die "could not read agent worker Node version"
(( source_node_major > 22 || (source_node_major == 22 && source_node_minor >= 18) )) || die "agent worker requires Node 22.18 or newer"
local -a worker_users ready missing_accounts missing caller_entries
local worker_user worker_home worker_group worker_node source_node source_node_version source_node_major source_node_minor
local phone_source phone_tmp worker_key worker_env_tmp worker_env_tmp_agent agent_upper worker_var old_worker_key caller_map caller_id
local groups entry path name
read -r -a worker_users <<< "${AGENT_PHONE_USERS:-${AGENT_PHONE_USER:-riotcoder}}"
[ "${#worker_users[@]}" -gt 0 ] || die "AGENT_PHONE_USERS must list at least one agent"
source_node="$(find /home/anthony/.local/share/mise/installs/node -maxdepth 4 -type f -name node -perm /111 2>/dev/null | sort -V | tail -n 1)"
[ -n "$source_node" ] || die "Node 22.18+ runtime not found for agent workers"
source_node_version="$("$source_node" --version 2>/dev/null || true)"
source_node_version="${source_node_version#v}"
source_node_major="${source_node_version%%.*}"
source_node_minor="${source_node_version#*.}"
source_node_minor="${source_node_minor%%.*}"
[[ "$source_node_major" =~ ^[0-9]+$ && "$source_node_minor" =~ ^[0-9]+$ ]] || die "could not read agent worker Node version"
(( source_node_major > 22 || (source_node_major == 22 && source_node_minor >= 18) )) || die "agent workers require Node 22.18 or newer"

phone_source="/home/anthony/www/qrypt.chat/app.env"
[ -r "$phone_source" ] || die "phone integration settings not found at $phone_source"
phone_tmp="$(mktemp)"
grep -E '^(TELNYX_API_KEY|TELNYX_AGENT_APP_ID|AGENT_PHONE_SECRET|PHONE_CALLER_ID|GROUPCALL_AGENT_CALLER_IDS)=' "$phone_source" > "$phone_tmp"
for name in TELNYX_API_KEY TELNYX_AGENT_APP_ID AGENT_PHONE_SECRET PHONE_CALLER_ID; do
grep -q "^$name=" "$phone_tmp" || { rm -f "$phone_tmp"; die "required phone setting $name is missing"; }
done
caller_map="$(sed -n 's/^GROUPCALL_AGENT_CALLER_IDS=//p' "$phone_tmp" | tail -n 1)"
if [ -z "$caller_map" ] && [ -f "$STATE_DIR/app.env" ]; then
caller_map="$(sed -n 's/^GROUPCALL_AGENT_CALLER_IDS=//p' "$STATE_DIR/app.env" | tail -n 1)"
fi
caller_map="${caller_map#\"}"; caller_map="${caller_map%\"}"; caller_map="${caller_map#\'}"; caller_map="${caller_map%\'}"
caller_fallback="$(sed -n 's/^PHONE_CALLER_ID=//p' "$phone_tmp" | tail -n 1)"
caller_fallback="${caller_fallback#\"}"; caller_fallback="${caller_fallback%\"}"; caller_fallback="${caller_fallback#\'}"; caller_fallback="${caller_fallback%\'}"
old_worker_key=""
if [ -f "$STATE_DIR/riotcoder-worker.env" ]; then
old_worker_key="$(sed -n 's/^GROUPCALL_AGENT_WORKER_KEY_RIOTCODER=//p' "$STATE_DIR/riotcoder-worker.env" | tail -n 1)"
fi
ready=()
missing_accounts=()
worker_env_tmp="$(mktemp)"
chmod 0600 "$worker_env_tmp"
for worker_user in "${worker_users[@]}"; do
worker_home="$(getent passwd "$worker_user" | cut -d: -f6)"
[ -n "$worker_home" ] || die "agent account '$worker_user' has no home directory"
worker_group="$(id -gn "$worker_user")"
groups=" $(id -nG "$worker_user") "
case "$groups" in *" sudo "*|*" wheel "*|*" admin "*) die "$worker_user has an admin group" ;; esac
case "$worker_user" in *[!a-z0-9]*|'') die "invalid agent name '$worker_user'" ;; esac
agent_upper="$(printf '%s' "$worker_user" | tr '[:lower:]' '[:upper:]')"
worker_var="GROUPCALL_AGENT_WORKER_KEY_$agent_upper"
worker_key="${!worker_var:-}"
if [ -z "$worker_key" ] && [ -f "$STATE_DIR/agent-worker.env" ]; then
worker_key="$(sed -n "s/^$worker_var=//p" "$STATE_DIR/agent-worker.env" | tail -n 1)"
fi
if [ -z "$worker_key" ] && [ "$worker_user" = riotcoder ]; then worker_key="$old_worker_key"; fi
if [ -z "$worker_key" ]; then worker_key="$(od -An -N32 -tx1 /dev/urandom | tr -d '[:space:]')"; fi
printf '%s=%s\n' "$worker_var" "$worker_key" >> "$worker_env_tmp"
printf -v "$worker_var" '%s' "$worker_key"
export "$worker_var"

worker_node="$worker_home/.local/share/agent-phone/node"
install -d -o "$worker_user" -g "$worker_group" -m 0750 "$worker_home/.local/share/agent-phone" "$worker_home/.local/bin" "$worker_home/.local/libexec" "$worker_home/.config/agent-phone" "$worker_home/.config/groupcall" "$worker_home/.config/qc-agents"
install -o "$worker_user" -g "$worker_group" -m 0755 "$source_node" "$worker_node"

phone_source="/home/anthony/www/qrypt.chat/app.env"
[ -r "$phone_source" ] || die "phone integration settings not found at $phone_source"
phone_tmp="$(mktemp)"
grep -E '^(TELNYX_API_KEY|TELNYX_AGENT_APP_ID|AGENT_PHONE_SECRET|PHONE_CALLER_ID)=' "$phone_source" > "$phone_tmp"
for name in TELNYX_API_KEY TELNYX_AGENT_APP_ID AGENT_PHONE_SECRET PHONE_CALLER_ID; do
grep -q "^$name=" "$phone_tmp" || { rm -f "$phone_tmp"; die "required phone setting $name is missing"; }
install -o "$worker_user" -g "$worker_group" -m 0750 "$source_node" "$worker_node"
install -o "$worker_user" -g "$worker_group" -m 0600 "$phone_tmp" "$worker_home/.config/agent-phone/phone.env.tmp"
caller_id="$caller_fallback"
IFS=',' read -r -a caller_entries <<< "$caller_map"
for entry in "${caller_entries[@]}"; do
if [ "${entry%%=*}" = "$worker_user" ]; then caller_id="${entry#*=}"; fi
done
install -o "$worker_user" -g "$worker_group" -m 0600 "$phone_tmp" "$worker_home/.config/agent-phone/phone.env"
rm -f "$phone_tmp"

worker_key="${GROUPCALL_AGENT_WORKER_KEY_RIOTCODER:-}"
if [ -z "$worker_key" ] && [ -f "$STATE_DIR/riotcoder-worker.env" ]; then
worker_key="$(sed -n 's/^GROUPCALL_AGENT_WORKER_KEY_RIOTCODER=//p' "$STATE_DIR/riotcoder-worker.env" | tail -n 1)"
if [ -n "$caller_id" ]; then
sed -i '/^PHONE_CALLER_ID=/d' "$worker_home/.config/agent-phone/phone.env.tmp"
printf 'PHONE_CALLER_ID=%s\n' "$caller_id" >> "$worker_home/.config/agent-phone/phone.env.tmp"
fi
if [ -z "$worker_key" ]; then
worker_key="$(od -An -N32 -tx1 /dev/urandom | tr -d '[:space:]')"
fi
put_root_file "$STATE_DIR/riotcoder-worker.env" 0600 <<EOF
GROUPCALL_AGENT_WORKER_KEY_RIOTCODER=$worker_key
EOF
export GROUPCALL_AGENT_WORKER_KEY_RIOTCODER="$worker_key"
worker_env_tmp="$(mktemp)"
cat > "$worker_env_tmp" <<EOF
mv "$worker_home/.config/agent-phone/phone.env.tmp" "$worker_home/.config/agent-phone/phone.env"
worker_env_tmp_agent="$(mktemp)"
cat > "$worker_env_tmp_agent" <<EOF
GROUPCALL_URL=https://group.call.pit.world
GROUPCALL_AGENT=riotcoder
GROUPCALL_AGENT=$worker_user
GROUPCALL_AGENT_WORKER_KEY=$worker_key
EOF
install -o "$worker_user" -g "$worker_group" -m 0600 "$worker_env_tmp" "$worker_home/.config/groupcall/worker.env"
rm -f "$worker_env_tmp"

install -o "$worker_user" -g "$worker_group" -m 0600 "$worker_env_tmp_agent" "$worker_home/.config/groupcall/worker.env"
rm -f "$worker_env_tmp_agent"
for prompt in phone-turn.md group-turn.md offtherecord-turn.md offtherecord-brief.md; do
install -o "$worker_user" -g "$worker_group" -m 0644 "$SRC_DIR/ops/prompts/$prompt" "$worker_home/.config/qc-agents/$prompt"
done
install -o "$worker_user" -g "$worker_group" -m 0644 "$SRC_DIR/ops/prompts/offtherecord-turn.md" "$worker_home/.config/qc-agents/podcast-turn.md"
else
worker_node="$worker_home/.local/share/mise/shims/node"
[ -x "$worker_node" ] || die "agent worker node runtime missing: $worker_node"
install -d -o "$worker_user" -g "$worker_group" -m 0750 "$worker_home/.local/share/agent-phone" "$worker_home/.local/bin"
fi
install -o "$worker_user" -g "$worker_group" -m 0750 "$SRC_DIR/ops/agent-phone.mjs" "$worker_home/.local/share/agent-phone/agent-phone.mjs"
cat > "$worker_home/.local/bin/agent-phone.tmp" <<EOF
install -o "$worker_user" -g "$worker_group" -m 0750 "$SRC_DIR/ops/agent-phone.mjs" "$worker_home/.local/share/agent-phone/agent-phone.mjs"
cat > "$worker_home/.local/bin/agent-phone.tmp" <<EOF
#!/bin/sh
export HOME=$worker_home
export QC_AGENT=$worker_user
exec $worker_node $worker_home/.local/share/agent-phone/agent-phone.mjs "\$@"
EOF
chown "$worker_user:$worker_group" "$worker_home/.local/bin/agent-phone.tmp"
chmod 0750 "$worker_home/.local/bin/agent-phone.tmp"
mv "$worker_home/.local/bin/agent-phone.tmp" "$worker_home/.local/bin/agent-phone"
if [ "$worker_user" = riotcoder ]; then
put_root_file /etc/systemd/system/groupcall-riotcoder-worker.service 0644 <<EOF
chown "$worker_user:$worker_group" "$worker_home/.local/bin/agent-phone.tmp"
chmod 0750 "$worker_home/.local/bin/agent-phone.tmp"
mv "$worker_home/.local/bin/agent-phone.tmp" "$worker_home/.local/bin/agent-phone"

missing=()
for path in "$worker_home/.config/agent-llm.json" "$worker_home/.codex/auth.json" "$worker_home/.config/qc/session.json" "$worker_home/.config/qc-agent/qc-passphrase" "$worker_home/.config/qc-agent/persona.md"; do
[ -f "$path" ] || missing+=("${path#"$worker_home"/}")
done
if [ "${#missing[@]}" -gt 0 ]; then
missing_accounts+=("$worker_user (${missing[*]})")
systemctl disable --now "groupcall-$worker_user-worker.timer" >/dev/null 2>&1 || true
continue
fi

put_root_file "/etc/systemd/system/groupcall-$worker_user-worker.service" 0644 <<EOF
$MARKER
[Unit]
Description=RiotCoder group.call phone worker
Description=$worker_user group.call phone worker
After=network-online.target group-call.service
Wants=network-online.target

[Service]
Type=oneshot
User=riotcoder
Group=riotcoder
WorkingDirectory=/home/riotcoder
Environment=HOME=/home/riotcoder
Environment=QC_AGENT=riotcoder
Environment=PATH=/home/riotcoder/.local/share/agent-phone:/home/riotcoder/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/riotcoder/.local/bin/agent-phone groupcall-worker --for 55 --agent riotcoder
User=$worker_user
Group=$worker_group
WorkingDirectory=$worker_home
Environment=HOME=$worker_home
Environment=QC_AGENT=$worker_user
Environment=PATH=$worker_home/.local/share/agent-phone:$worker_home/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=$worker_home/.local/bin/agent-phone groupcall-worker --for 55 --agent $worker_user
TimeoutStartSec=65min
EOF
put_root_file /etc/systemd/system/groupcall-riotcoder-worker.timer 0644 <<EOF
put_root_file "/etc/systemd/system/groupcall-$worker_user-worker.timer" 0644 <<EOF
$MARKER
[Unit]
Description=Run RiotCoder group.call worker each minute
Description=Run $worker_user group.call worker each minute

[Timer]
OnCalendar=*-*-* *:*:00
Expand All @@ -379,10 +412,18 @@ Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now groupcall-riotcoder-worker.timer >/dev/null
ready+=("$worker_user")
done
rm -f "$phone_tmp"
put_root_file "$STATE_DIR/agent-worker.env" 0600 < "$worker_env_tmp"
rm -f "$worker_env_tmp"
rm -f "$STATE_DIR/riotcoder-worker.env"
systemctl daemon-reload
for worker_user in "${ready[@]}"; do systemctl enable --now "groupcall-$worker_user-worker.timer" >/dev/null; done
log "agent phone workers installed for ${ready[*]:-(none)}"
if [ "${#missing_accounts[@]}" -gt 0 ]; then
warn "phone workers skipped until each account has its own credentials: ${missing_accounts[*]}"
fi
log "agent worker installed for $worker_user"
}

phase_build() {
Expand Down Expand Up @@ -422,7 +463,7 @@ Environment=NODE_ENV=production
Environment=PATH=$unit_path
EnvironmentFile=-$STATE_DIR/db.env
EnvironmentFile=-$STATE_DIR/app.env
EnvironmentFile=-$STATE_DIR/riotcoder-worker.env
EnvironmentFile=-$STATE_DIR/agent-worker.env
ExecStart=$STATE_DIR/run.sh
Restart=always
RestartSec=2
Expand Down
Loading
Loading