Activate and use the Telegram surface¶
This runbook connects a Telegram bot to Veetbot so the owner can chat, answer questions, and approve actions from a phone. The bot uses long polling from the surface role on the production host; it needs no inbound port, webhook, or Nginx change. The governing design is Inbound Surfaces; the WhatsApp integration runbook adds the optional second channel on the same role.
Finish line¶
Activation is complete only when:
- the bot exists and joins no groups;
- its token is one owner-only file on the host and in Doppler, nowhere else;
- the application and surface environments agree on the surface flags, and
veetbot-surface.serviceis active; - one owner pairing completes through
/pair <code>; and - the live smoke below passes: an unpaired refusal, pairing, a run, a question,
an approval with
/approve, and revocation before the next message.
Safety rules¶
- Never paste the bot token into chat, Git, tickets, command arguments, shell history, logs, or milestone evidence. It moves from BotFather to Doppler, and from Doppler to the host over a pipe.
- The token file is a regular, non-symlink file owned by
veetbotwith mode0600. Only the surface role reads it; every other role refuses to start with a surface secret-file variable in its environment. - The surface environment holds no API bearer and no model, web, browser, or
sandbox credential; the surface role refuses to start with a provider key,
including
BLAND_API_KEY_FILEeven when calling is configured. Its database login,veetbot_surface, reaches only the tables the surface uses. - A pairing code is shown once, expires in ten minutes, and is single use. Five wrong codes lock that sender for an hour.
- Every production command below runs as
rootorveetbotby SSH key. None asks for a password.
Phase 1 — Create the bot¶
In Telegram:
- Open
@BotFather(the verified account) and send/newbot. Give it a display name such asVeetbotand a username ending inbot. - BotFather replies with the token. Copy it only into Doppler: project
veetbot, configprd, secretTELEGRAM_BOT_TOKEN, either in the Doppler dashboard or withdoppler secrets set TELEGRAM_BOT_TOKEN --project veetbot --config prd, pasting at the prompt. - Send
/setjoingroups, choose the bot, and select Disable. Veetbot answers direct messages only; this stops anyone adding the bot to a group. - Optionally send
/setcommandswith:
new - start a new conversation
stop - cancel the running request
status - check the connection
help - list the commands
Phase 2 — Install the token file¶
From the owner's Mac, pipe the token from Doppler to the host. It never appears in a command line or in either shell's history:
doppler secrets get TELEGRAM_BOT_TOKEN --project veetbot --config prd --plain | ssh root@api.veetbot.com 'set -e; d=/etc/veetbot/secrets; test -d "$d" || install -d -o veetbot -g veetbot -m 0700 "$d"; umask 077; t=$(mktemp "$d/.telegram.XXXXXX"); cat > "$t"; chown veetbot:veetbot "$t"; chmod 0600 "$t"; mv -f "$t" "$d/telegram-bot-token"; stat -c "%a %U:%G %s bytes %n" "$d/telegram-bot-token"; runuser -u veetbot -- test -r "$d/telegram-bot-token" && echo "readable by veetbot"'
It must print 600 veetbot:veetbot, a size of about 46 bytes, and
readable by veetbot.
Phase 3 — Configure both environments¶
The surface role reads its own environment, /etc/veetbot/veetbot-surface.env,
and connects to the database as its own login, veetbot_surface, which holds
only the grants the deployment guide lists; the release
refuses any other login once the channel is on. This command creates or repairs
that login with a generated password and exactly those grants, checks that it
connects, and writes it into the surface environment. A missing environment is
created from the application environment's identity lines; an existing one has
only its DATABASE_URL line replaced, with a backup beside it. It prints no
secret, restarts the role if it is running, and rotates the password when run
again:
ssh root@api.veetbot.com 'bash -s' <<'EOF'
set -euo pipefail
app=/etc/veetbot/veetbot.env
dst=/etc/veetbot/veetbot-surface.env
url=$(sed -n 's/^DATABASE_URL=//p' "$app" | tail -n 1)
case "$url" in postgresql*://*@*) ;; *) echo "no DATABASE_URL in $app"; exit 1 ;; esac
pw=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
surface_url="${url%%://*}://veetbot_surface:$pw@${url#*@}"
docker exec -i veetbot-postgres-1 psql -q -v ON_ERROR_STOP=1 -U agent -d agent <<SQL
SELECT 'CREATE ROLE veetbot_surface' WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'veetbot_surface')\gexec
ALTER ROLE veetbot_surface LOGIN PASSWORD '$pw' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS;
GRANT CONNECT ON DATABASE agent TO veetbot_surface;
GRANT USAGE ON SCHEMA public TO veetbot_surface;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM veetbot_surface;
GRANT SELECT ON agents, alembic_version, approvals, checkpoints, delegations, derived_event_keys, devices, events, export_consent, notification_deliveries, notification_outbox, notification_run_receipts, process_events, projection_watermarks, runs, session_history_items, sessions, surface_inbound_receipts, surface_pairing_codes, surface_pairings, surface_replies, surface_sender_lockouts, surface_sessions, tool_invocations TO veetbot_surface;
GRANT INSERT ON checkpoints, derived_event_keys, devices, events, notification_deliveries, process_events, projection_watermarks, runs, session_history_items, sessions, surface_inbound_receipts, surface_pairings, surface_sender_lockouts, surface_sessions TO veetbot_surface;
GRANT UPDATE ON approvals, delegations, devices, events, notification_outbox, projection_watermarks, runs, sessions, surface_inbound_receipts, surface_pairing_codes, surface_pairings, surface_replies, surface_sender_lockouts, surface_sessions, tool_invocations TO veetbot_surface;
GRANT DELETE ON surface_sender_lockouts TO veetbot_surface;
SQL
SURFACE_URL=$surface_url /opt/veetbot/current/.venv/bin/python -B -c 'import asyncio, os, asyncpg
async def check():
connection = await asyncpg.connect(os.environ["SURFACE_URL"].replace("+asyncpg", "", 1))
await connection.fetchval("SELECT count(*) FROM surface_pairings")
await connection.close()
asyncio.run(check())' </dev/null
echo "veetbot_surface connects"
umask 027
if [ -e "$dst" ]; then
cp -p "$dst" "$dst.bak-surface-login"
SURFACE_URL=$surface_url awk '/^DATABASE_URL=/ { print "DATABASE_URL=" ENVIRON["SURFACE_URL"]; next } { print }' "$dst.bak-surface-login" > "$dst.new"
else
{ echo "DATABASE_URL=$surface_url"; grep -E '^(PGSSLMODE|AUTH_TENANT_ID|AUTH_PRINCIPAL_ID|AGENT_CONFIG_DIR)=' "$app" || true; } > "$dst.new"
grep -q '^PGSSLMODE=' "$dst.new" || echo PGSSLMODE=disable >> "$dst.new"
grep -q '^AGENT_CONFIG_DIR=' "$dst.new" || echo AGENT_CONFIG_DIR= >> "$dst.new"
printf '%s\n' DEPLOYMENT_MODE=production AUTH_MODE=token AUTH_ROLES=surface AUTH_SCOPES=run.read,run.write,run.cancel,surface.read,surface.write,approval.read,approval.resolve,schedule.read,schedule.write AGENT_SURFACE_API_ENABLED=1 AGENT_SURFACE_WORKER_ENABLED=1 AGENT_SURFACE_WHATSAPP_ENABLED=0 AGENT_SURFACE_TELEGRAM_TOKEN_FILE=/etc/veetbot/secrets/telegram-bot-token >> "$dst.new"
fi
chown root:veetbot "$dst.new"
chmod 0640 "$dst.new"
mv -f "$dst.new" "$dst"
grep -q '^DATABASE_URL=postgresql[^:]*://veetbot_surface:' "$dst" && echo "$dst uses veetbot_surface"
cut -d= -f1 "$dst" | tr '\n' ' '; echo
if systemctl is-active --quiet veetbot-surface; then systemctl restart veetbot-surface; systemctl is-active veetbot-surface; fi
EOF
It must print veetbot_surface connects and that the file uses
veetbot_surface, followed by the variable names. AUTH_SCOPES here is the
paired principal's scope ceiling on this channel: a pairing's grant is
intersected with it on every message. The schedule scopes let the smoke create
a reminder, which needs approval.
Then turn on the surface flags and add the two surface scopes in the application environment. The command is idempotent, keeps a backup, and prints the scope and flag lines so you can confirm notifications are on:
ssh root@api.veetbot.com 'set -e; f=/etc/veetbot/veetbot.env; cp -p "$f" "$f.bak-telegram"; sed -i -E "/^AUTH_SCOPES=/{/surface\.read/!s/$/,surface.read/;/surface\.write/!s/$/,surface.write/}" "$f"; for v in AGENT_SURFACE_API_ENABLED AGENT_SURFACE_WORKER_ENABLED; do if grep -q "^$v=" "$f"; then sed -i -E "s/^$v=.*/$v=1/" "$f"; else echo "$v=1" >> "$f"; fi; done; grep -q "^AGENT_SURFACE_WHATSAPP_ENABLED=" "$f" || echo AGENT_SURFACE_WHATSAPP_ENABLED=0 >> "$f"; grep -E "^(AUTH_SCOPES|AGENT_SURFACE_[A-Z_]+_ENABLED|AGENT_NOTIFICATION_(API|DISPATCH)_ENABLED)=" "$f"'
Both AGENT_NOTIFICATION_API_ENABLED and AGENT_NOTIFICATION_DISPATCH_ENABLED
must print 1: approval prompts and questions reach the chat through the
notification outbox. The printed AUTH_SCOPES must contain every scope a
pairing will grant, because a code can grant only scopes its minter holds.
Phase 4 — Deploy¶
Complete Phases 2 and 3 before the deployment that should activate the
channel: the release preflight refuses a missing surface environment, a missing
token file, flags that disagree, or a surface database login holding anything
other than its grants, and it then changes nothing. The release
installs, enables, and restarts veetbot-surface.service with the other units.
Deploy through the reviewed main pipeline, or rerun the latest main
deployment in CircleCI if the code is already live. Then check the role:
ssh root@api.veetbot.com 'systemctl is-active veetbot-surface; journalctl -u veetbot-surface -n 20 -o cat --no-pager'
It must print active, and the journal must show no ConfigurationError.
The release has already validated the login; to check it again at any time:
ssh veetbot@api.veetbot.com 'cd /opt/veetbot/current && set -a && . /etc/veetbot/veetbot-surface.env && set +a && .venv/bin/python -m scripts.check_surface_database_permissions'
It prints OK: surface database role 'veetbot_surface' has the required least
privileges, or one FAIL: line per difference.
Phase 5 — Pair the owner¶
The surface role registers the bot as a surface when it first starts. List
surfaces from the current release, as veetbot, and note the id of the row
whose platform is telegram:
ssh veetbot@api.veetbot.com 'cd /opt/veetbot/current && set -a && . /etc/veetbot/veetbot.env && . ./.release.env && set +a && .venv/bin/agent surface list'
Mint a one-time code, replacing SURFACE_ID. The command prints the code once:
ssh veetbot@api.veetbot.com 'cd /opt/veetbot/current && set -a && . /etc/veetbot/veetbot.env && . ./.release.env && set +a && .venv/bin/agent surface pair SURFACE_ID --scope run.read --scope run.write --scope run.cancel --scope approval.read --scope approval.resolve --scope schedule.read --scope schedule.write --label "Owner phone"'
Within ten minutes, send /pair CODE to the bot as a direct message. It
answers "Pairing complete. You can now message Veetbot." List the pairing
without exposing the code:
ssh veetbot@api.veetbot.com 'cd /opt/veetbot/current && set -a && . /etc/veetbot/veetbot.env && . ./.release.env && set +a && .venv/bin/agent surface pairings SURFACE_ID'
Everyday use¶
Send an ordinary direct message to start or continue a conversation; it opens as an ordinary chat that the Apple client shows too. The commands are:
/new start a new conversation
/stop cancel the running request
/status check the connection
/help list the commands
/approve ID approve a pending action once
/deny ID deny a pending action
While Veetbot is working, another message is refused with "Still working; /stop to cancel." When Veetbot asks a question, your next message answers it. Groups and media are refused. Each run a message starts may spend up to USD 10 and is told to answer once USD 2 remains; the surface channel as a whole may spend USD 25 a day and USD 250 a month.
Live smoke and evidence¶
Use harmless text and record only timestamps, results, and identifiers:
- [ ] Unpaired refusal. Before pairing, send
hello. The bot answers "This sender is not paired. Send /pair followed by a code." and nothing appears in the Apple client. - [ ] Pairing. Pair as in Phase 5. Sending the same
/pair CODEagain answers "This chat is already paired with Veetbot." and never reaches the model; from a chat that is not paired, a used code answers "That pairing code is invalid or expired." - [ ] A run. Send
What is 17 times 23?. One reply arrives, and the conversation appears in the Apple client. - [ ] A question. Send
Before you answer, ask me one clarifying question using the ask-user tool: suggest a name for my new houseplant.The question arrives in the chat; answer it with a plain message, and the final reply follows. - [ ] An approval. Send
Remind me in 10 minutes to drink water.The bot sends "Approval needed", the summary, anID:line, and/approve ID or /deny ID. Send exactly/approve ID. It answers "Approval resolved.", and the reply confirms the reminder. Sending the same/approve IDagain changes nothing. - [ ] Commands.
/statusand/helpanswer;/newanswers "A new chat session will start with your next message.";/stopduring a long answer answers "The active run was stopped." - [ ] Revocation. List pairings, then revoke the owner pairing, replacing
PAIRING_ID:
ssh veetbot@api.veetbot.com 'cd /opt/veetbot/current && set -a && . /etc/veetbot/veetbot.env && . ./.release.env && set +a && .venv/bin/agent surface revoke PAIRING_ID'
The very next message is answered "This sender is not paired." Pair again with a new code to keep using the bot. 8. [ ] No leaks. The surface journal and the evidence contain no token, code, or message text:
ssh root@api.veetbot.com 'journalctl -u veetbot-surface --since "1 hour ago" -o cat --no-pager | tail -n 50'
Troubleshooting¶
- "The daily surface budget is exhausted." Surface runs today have spent, or are reserving, the USD 25 daily ceiling; each run in flight reserves USD 10, so a third concurrent run is refused this way. It resets at 00:00 UTC.
- "Too many requests are running" Four surface runs are in flight. Wait for one to finish.
- "The agent has a question" or "Approval needed" without detail. The
pairing lacks
run.readorapproval.read; pair again with them. - No approval prompt at all. Check both notification flags in Phase 3.
release failed: surface database role does not satisfy its least-privilege allowlist. The deployment log'sFAIL:lines name each missing or surplus privilege. Rerun the Phase 3 command, which reapplies the grants, then the deployment. A surplus the command does not remove comes from another source (a role membership, ownership, or a grant toPUBLIC); the deployment guide explains how to remove it.permission denied for tablein the surface journal. A release changed what the surface reads or writes without updating its grants; rerun the Phase 3 command from that release's runbook.- The unit is not active. Read
ssh root@api.veetbot.com 'journalctl -u veetbot-surface -n 50 -o cat --no-pager'. AConfigurationErrornames the variable or file to fix. - A reply never arrives. Replies retry for about four hours and then stop; the answer is always in the Apple client.
Disable or rotate¶
To disable the channel, set AGENT_SURFACE_API_ENABLED=0 and
AGENT_SURFACE_WORKER_ENABLED=0 in both environment files and deploy; the
release disables veetbot-surface.service. Pairings and conversations remain
as records.
To rotate the token, send /revoke to BotFather for the bot, store the new
token in Doppler, rerun the Phase 2 command, and restart the role: