gmail-tools — Telegram Bot (google-worker-bot) (operations)
Workflow: google-worker-bot
n8n ID: BBVD0cAN6uuIbcqr (bms-4 — https://n8n.bms-4.infra.zintegrowana.online)
Host: bms-4 (54.36.123.110), runs as claude-runner
Repo under management: radieu/gmail-tools cloned at /opt/gmail-tools
Created: 2026-08-01 · p24-infra#4819 (Return-To: radieu/gmail-tools#2715)
Status: ✅ ACTIVE since 2026-08-11 (p24-infra#2715 — infra-task worker on bms-4). Activation
checklist below cleared: telegram credential present + bound, bms-4-root-ssh proven (p24-infra#4785
closed, sibling daily agent runs over it), Gmail OAuth grant live (that day’s daily-agent run passed
the OAuth preflight). Owner-side end-to-end round-trip (send a Telegram message) is the only remaining
confirmation and is inherently owner-only. Pause/resume via §Operations.
AI system: registered — dev_r_ai_systems.gmail-tools-telegram-bot (minimal EU AI Act tier;
injection blast radius assessed separately — host dimension mitigated at ship via --allowedTools,
see §Compliance and C-R6)
Sibling of the Daily Inbox Agent (#4724): same host, same runner hardening, same repo-committed allowlist. The difference is the trigger — an ad-hoc Telegram message from the single authorized owner instead of a 07:00 cron — and that the agent’s prompt is the message text, not a fixed playbook line.
This workflow replaces an earlier inactive prototype of the same name/id (created 2026-07-04) that routed
/cal/drive/sheetcommands straight to CLI scripts over SSH with the auth guard disabled (allowedChatId = -1) and no--allowedTools. That version never ran. The rebuild below is the canonical one.
Activation checklist
Cleared 2026-08-11 (p24-infra#2715). All three blockers below were verified clear and the workflow was activated. Kept as the reference for what must hold on any future re-activation (e.g. after a deliberate pause or a redeploy). Blocker 3 recurs by design — the Google client is in Testing mode, so the OAuth grant expires ~weekly; the runner fails safe on an expired grant (
rc=13→ the owner gets a re-auth message, no mailbox action taken).
The workflow is complete. It was deactivated for three reasons; all had to clear before activating.
| # | Blocker | Owner | Tracked |
|---|---|---|---|
| 1 | n8n telegram credential for the gmail bot must exist and hold the current bot token | secret-manager / infra | this issue — see below |
| 2 | n8n bms-4-root-ssh credential authenticates (shared gate — the key it stores must be in root@bms-4’s authorized_keys) | secret-manager | p24-infra#4785 |
| 3 | Gmail OAuth refresh token is live (Google client is in Testing mode, so grants die every 7 days) | repo owner (needs a browser) | radieu/gmail-tools#2715 · #4820 |
Blocker 1 — the n8n telegram credential
The workflow references an n8n telegramApi credential gmail-worker-bot (id
ZB4JIcbCvN2qebSv). Its token must be the gmail bot token stored in
secrets/gmail-tools.env.sops under key GMAIL_TOOLS_TELEGRAM_BOT_TOKEN.
Key-name note. The originating issue #4819 referred to the token as
TELEGRAM_BOT_GMAIL_ASSISTANT_TOKEN. The canonical key actually present insecrets/gmail-tools.env.sopsisGMAIL_TOOLS_TELEGRAM_BOT_TOKEN— treat the issue’s name as an alias. No SOPS change is made by this workflow.
The n8n public API cannot create or patch credential secrets, so this is a one-time manual step in the n8n UI (Credentials → gmail-worker-bot → Bot Token). Read the value silently from SOPS to paste it — never echo it:
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
# prints ONLY into your clipboard/paste buffer via the UI — do not echo to a terminal
sops --decrypt --input-type dotenv --output-type dotenv \
/opt/p24-infra/secrets/gmail-tools.env.sops | grep "^GMAIL_TOOLS_TELEGRAM_BOT_TOKEN=" >/dev/null \
&& echo "token present in SOPS" || echo "token MISSING in SOPS"Blocker 3 — Gmail OAuth grant (shared with the daily agent)
Identical to the daily agent’s blocker — the same /opt/gmail-tools clone and the same
~/.gmail-mcp/{token.json,gcp-oauth.keys.json} back both workflows. The runner’s preflight fails
fast with rc=13 if the grant is rejected. Re-auth (needs a browser, so a human must do it):
ssh root@54.36.123.110
su -s /bin/bash claude-runner -c 'cd /opt/gmail-tools && USERPROFILE=$HOME node auth.js'Permanent fix: move the Google OAuth client from Testing to In production so refresh tokens stop expiring on a 7-day timer.
Import + activate
The repo JSON is canonical. Import first (deactivated stays deactivated), then activate only once 1–3 are clear.
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
N8N_HOST="https://n8n.bms-4.infra.zintegrowana.online"
K=$(sops --decrypt --input-type dotenv --output-type dotenv \
/opt/p24-infra/secrets/n8n-bms4.env.sops | grep "^BMS4_N8N_API_KEY=" | cut -d= -f2-)
# 1. Re-import — repo JSON is the source of truth (name/nodes/connections/settings only).
curl -s -X PUT -H "X-N8N-API-KEY: $K" -H "Content-Type: application/json" \
"$N8N_HOST/api/v1/workflows/BBVD0cAN6uuIbcqr" \
--data-binary @infra-src/n8n-workflows/google-worker-bot.json | jq -r '.name'
# 2. Then activate (only after blockers 1-3 clear).
curl -s -X POST -H "X-N8N-API-KEY: $K" \
"$N8N_HOST/api/v1/workflows/BBVD0cAN6uuIbcqr/activate" | jq .activeThe n8n API rejects read-only keys (
id,active,createdAt, …) in aPUTbody. The committedinfra-src/n8n-workflows/google-worker-bot.jsonalready contains only the four allowed keys. (n8n-workflows/google-worker-bot_BBVD0cAN6uuIbcqr.jsonis the full-export snapshot for reference; do not PUT it verbatim.)
What it does
On a Telegram message from the authorized owner only, the workflow SSHes to bms-4 and runs the
gmail-tools agent against radieu@gmail.com with the message text as the prompt:
claude --allowedTools <allowlist> -p "<the Telegram message text>"
Examples the owner can send: “sprawdź teraz moją skrzynkę”, “uruchom dzienny przegląd zgodnie z
playbookiem sekcja 14”, “zarchiwizuj stare pgkpuck”. /start and /help (and an empty message)
short-circuit to a static help reply without invoking the agent.
The allowlist (same shape as the daily agent, plus two read-only status scripts)
Read(docs/gmail-filtering-playbook.md) Read(docs/priorities.md) Edit(docs/priorities.md)
Bash(node scripts/gmail-query.js *) Bash(node scripts/gmail-mutate.js *)
Bash(node scripts/sheets-sync.js --daily) Bash(node scripts/send-digest.js *)
Bash(node scripts/archive-old-pgkpuck.js)
Bash(node scripts/inbox-status.js *) Bash(node scripts/inbox-audit.js *) ← added for on-demand status
inbox-status.js and inbox-audit.js are read-only and exist on-disk in
/opt/gmail-tools/scripts/; they cover ad-hoc “check my inbox now” requests. Every entry points at
a named, purpose-built script — no bare Bash, no Write, no arbitrary node -e. This is the
issue’s MANDATORY requirement: the bot ships with --allowedTools from the start, never
--dangerously-skip-permissions.
Node chain
| # | Node | Purpose |
|---|---|---|
| 1 | Telegram Trigger | telegramTrigger, credential gmail-worker-bot (ZB4JIcbCvN2qebSv) |
| 2 | Check Authorized User | if — message.from.id == 8155641922 (owner radieu, the same id the telegram-claude-bot allowlist uses). False → Ignore Unauthorized (NoOp, silent drop) |
| 3 | Prepare Input | Code — extract text + chatId, flag /start /help / empty as isHelp |
| 4 | Is Help/Empty | if — true → Help Reply; false → Build SSH Command |
| 5 | Help Reply | Code — static usage text (no agent run) |
| 6 | Build SSH Command | Code — generates the per-execution nonce, base64-encodes the prompt, base64-wraps the hardened runner so no quoting survives ssh → su → bash |
| 7 | Run on bms-4 | ssh node, credential bms-4-root-ssh (W8fOGECM0UwCagjd) |
| 8 | Check Result | Code — parses stdout for the nonce-tagged marker; returns the agent’s answer (stderr) or a friendly error text |
| 9 | Send Reply | telegram — replies to the originating chat |
Failure alerting
Two classes, handled differently because this bot is attended (unlike the daily cron):
- Agent-level failures (
GWORKER_FAIL:<nonce> rc=NN) are returned to the requesting user in the Telegram reply, prefixed⚠️. The user sees the actionable message immediately. - SSH/infra failures (auth rejected, host unreachable) throw at the
Run on bms-4node, so n8n’ssettings.errorWorkflow = "gG3SsUPtcqgtsbje"(error_flow) fires — Discord embed + anerror-logrow in Supabaseproc_log, the same handler the daily agent uses.
The control channel is nonce-tagged, and stdout-only
Build SSH Command generates a random runId per execution and injects it into the runner. Every
status line the runner writes to stdout carries it — GWORKER_OK:<runId> /
GWORKER_FAIL:<runId> rc=NN :: … — and Check Result accepts a marker only when the nonce matches
this execution’s.
More importantly, the two streams are separated: stdout carries runner-generated markers and
nothing else, while the agent’s answer — which can quote inbound email verbatim — goes to stderr
and the run log. Check Result parses stdout only, and passes the stderr tail through to the user
purely as display text (never parsed for control flow). That separation is what makes the channel
unforgeable — the same fix landed for the daily agent (#4724, PR #4788 review). Here the sender is
already restricted to the authorized owner, so this is defence-in-depth rather than the primary
control.
The prompt itself is base64-encoded in Build SSH Command and decoded into a shell variable on
bms-4, passed to claude as a single -p argument — it is never interpolated into the runner
body, so a message containing shell metacharacters cannot inject shell. --allowedTools is the
real containment; this keeps the transport safe too.
Runner failure markers (each prefixed GWORKER_FAIL:<runId>):
| Marker | Meaning | Fix |
|---|---|---|
rc=10 | run log not writable — /var/log/p24-infra-workers missing or not writable by claude-runner | mkdir -p /var/log/p24-infra-workers && chown claude-runner … on bms-4 |
rc=11 | /opt/gmail-tools missing | Re-run the p24-infra#4721 clone step |
rc=12 | client_id/client_secret/refresh_token missing or unreadable under $HOME/.gmail-mcp | Re-run the #4721 credential placement |
rc=13 | Google rejected the OAuth refresh token — error name quoted | Re-authenticate (see Blocker 3) |
rc=14 | Claude usage limit hit mid-run (matched in the transcript’s last 5 lines) | Wait for quota reset |
rc=15 | token endpoint unreachable (DNS/egress/timeout) — not an expired grant | Check bms-4 egress to oauth2.googleapis.com |
rc=16 | empty prompt after base64 decode | Client sent no usable text; retry with content |
rc=124 | agent exceeded the 30-minute budget | Inspect the run log; consider raising timeout |
rc=1 | claude exited non-zero for anything else — claude’s real code is quoted | Read the run log |
rc=10–rc=16 are the runner’s own marker codes; a non-zero claude exit is remapped to rc=1
(with claude’s real code in the message) so it never collides with them.
Run transcript and its retention
Full agent output for every run is kept on bms-4 at
/var/log/p24-infra-workers/gmail-telegram-bot-<UTC-timestamp>.log; the Telegram reply and n8n
execution only receive the tail.
The transcript can quote third-party senders and subjects, so it is personal data
(docs/ai-risk-data-governance.md Part C, C.4):
| Control | Implementation |
|---|---|
| Not world-readable | the runner sets umask 077 and chmod 600 on the file it creates (the shared /var/log/p24-infra-workers is mode 2755 and sibling logs are 0666, so it is set per-file) |
| Retention — 30 days on bms-4 | the runner prunes gmail-telegram-bot-*.log older than RETAIN_DAYS=30 at the start of each run; the glob is scoped to this workflow’s own filenames |
| Retention — 7 days in n8n | the execution tail is purged by n8n’s EXECUTIONS_DATA_PRUNE=true / EXECUTIONS_DATA_MAX_AGE=168 |
If the workflow is ever deleted, remove the leftover gmail-telegram-bot-*.log transcripts too —
nothing else prunes them once the runner stops running.
Operations
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
N8N_HOST="https://n8n.bms-4.infra.zintegrowana.online"
K=$(sops --decrypt --input-type dotenv --output-type dotenv \
/opt/p24-infra/secrets/n8n-bms4.env.sops | grep "^BMS4_N8N_API_KEY=" | cut -d= -f2-)
# recent runs
curl -s -H "X-N8N-API-KEY: $K" \
"$N8N_HOST/api/v1/executions?workflowId=BBVD0cAN6uuIbcqr&limit=10" \
| jq -r '.data[] | "\(.id) \(.status) \(.startedAt)"'
# pause / resume
curl -s -X POST -H "X-N8N-API-KEY: $K" "$N8N_HOST/api/v1/workflows/BBVD0cAN6uuIbcqr/deactivate"
curl -s -X POST -H "X-N8N-API-KEY: $K" "$N8N_HOST/api/v1/workflows/BBVD0cAN6uuIbcqr/activate"Run the agent by hand (same command the workflow issues, minus the Telegram plumbing):
ssh root@54.36.123.110
su -s /bin/bash claude-runner -c 'export USERPROFILE=$HOME; cd /opt/gmail-tools && \
claude --allowedTools "Read(docs/gmail-filtering-playbook.md)" "Bash(node scripts/inbox-status.js *)" \
-p "sprawdź teraz moją skrzynkę"'Capacity
bms-4 allows 4 parallel claude sessions. Each request takes one slot for the length of a run
(capped at 30 min), triggered on demand — no fixed cadence, and no contention with the
infra-task / dev-issue workers or the daily inbox agent beyond the shared 4-slot budget.
Secrets
The workflow adds nothing to SOPS. It reuses:
- the gmail-tools credential files placed by p24-infra#4721 (
~/.gmail-mcp/{token.json,gcp-oauth.keys.json}), - the existing n8n
bms-4-root-sshcredential, - the gmail bot token already in
secrets/gmail-tools.env.sops(GMAIL_TOOLS_TELEGRAM_BOT_TOKEN), which must be present in the n8ngmail-worker-bottelegram credential (Blocker 1).
Compliance — EU AI Act
Registered at creation (not activation), per CLAUDE.md §Compliance:
| Record | Value |
|---|---|
dev_r_ai_systems | system_name = gmail-tools-telegram-bot, service_name = n8n-bms4, status = planned |
| Risk tier (EU AI Act) | minimal — no Annex III category |
| Injection blast radius (security, assessed separately) | host dimension mitigated at ship via --allowedTools (Option A); residual host-account exposure is Option B (#4815), out of scope |
compliance_status | compliant |
dev_r_services | n8n-bms4/gmail-tools-telegram-bot (child element of the gmail-tools service row) |
| Reproducible from the repo | supabase/migrations/20260801120000_gmail_telegram_bot_ai_system_4819.sql |
| Narrative | docs/eu-ai-act-compliance.md §4 · docs/ai-risk-data-governance.md Part C (C-R6) |
Why minimal. The agent scores, ranks and evaluates no one; it allocates no work to any person; it makes no decision about a natural person. The only person whose affairs it touches is the account owner, who is also its operator and the only authorized sender. Article 52 transparency is not engaged either — the sole recipient of AI-generated text is that same person.
Minimal is a classification, not a security rating. What a compromised run could reach is assessed separately under C-R6. Two things make this bot’s exposure smaller than the daily agent’s was at creation:
- (a) Host dimension shipped mitigated. The runner uses
claude --allowedToolsfrom day one, so the agent’s own tool surface is a fixed allowlist of gmail-tools scripts — no bareBash, noWrite, no arbitrarynode -e. It cannot shell out to read the on-diskvps_root_keyor GitHub PAT inclaude-runner’s home. That is Option A applied at the source (the daily agent’s #4724 fix), not deferred. - (b) Input surface is narrower. Only the authorized owner (Telegram
from.idallowlist) can reach the agent — not arbitrary inbound email. The nonce-tagged, stdout-only control channel is defence-in-depth on top of that.
The residual is the host-account question shared by every claude-runner process on bms-4
(the OS user’s home still holds fleet credentials) — Option B in docs/ai-risk-data-governance.md,
tracked as #4815, explicitly out of scope for this workflow.
Gmail dimension. The live grant carries only gmail.modify, gmail.readonly, gmail.labels
— no gmail.settings.* (cannot create filters/forwarding), no hard-delete scope. Worst case is
mislabelled/archived/trashed mail, all reversible; trashed mail is recoverable for 30 days. If the
granted scopes are ever widened, re-assess C-R6 before the next run.
Personal data. Inbox content is personal data and includes third-party correspondents. It is
read in place via the Gmail API and never copied to Supabase or any other store. The per-run
transcript under /var/log/p24-infra-workers/ is the only durable artefact on bms-4 that can quote
subjects and senders; its 0600 mode and 30-day prune are implemented in the runner (§Run
transcript and its retention), alongside the 7-day n8n execution TTL.
See also
docs/gmail-tools-daily-agent-operations.md— the daily cron sibling this mirrorsdocs/telegram-claude-bot-operations.md— the single-authorized-user Telegram auth pattern reused heredocs/playbooks/n8n/n8n-ssh-worker-pattern.md— the SSH →claude -ppatterndocs/gmail-tools-operations.md— the parentgmail-toolsservice (clone, SOPS, credentials)docs/eu-ai-act-compliance.md·docs/ai-risk-data-governance.mdPart C — AI-system registration