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 /sheet commands 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.

#BlockerOwnerTracked
1n8n telegram credential for the gmail bot must exist and hold the current bot tokensecret-manager / infrathis issue — see below
2n8n bms-4-root-ssh credential authenticates (shared gate — the key it stores must be in root@bms-4’s authorized_keys)secret-managerp24-infra#4785
3Gmail 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 in secrets/gmail-tools.env.sops is GMAIL_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 .active

The n8n API rejects read-only keys (id, active, createdAt, …) in a PUT body. The committed infra-src/n8n-workflows/google-worker-bot.json already contains only the four allowed keys. (n8n-workflows/google-worker-bot_BBVD0cAN6uuIbcqr.json is 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

#NodePurpose
1Telegram TriggertelegramTrigger, credential gmail-worker-bot (ZB4JIcbCvN2qebSv)
2Check Authorized Userifmessage.from.id == 8155641922 (owner radieu, the same id the telegram-claude-bot allowlist uses). False → Ignore Unauthorized (NoOp, silent drop)
3Prepare InputCode — extract text + chatId, flag /start /help / empty as isHelp
4Is Help/Emptyif — true → Help Reply; false → Build SSH Command
5Help ReplyCode — static usage text (no agent run)
6Build SSH CommandCode — generates the per-execution nonce, base64-encodes the prompt, base64-wraps the hardened runner so no quoting survives ssh → su → bash
7Run on bms-4ssh node, credential bms-4-root-ssh (W8fOGECM0UwCagjd)
8Check ResultCode — parses stdout for the nonce-tagged marker; returns the agent’s answer (stderr) or a friendly error text
9Send Replytelegram — 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-4 node, so n8n’s settings.errorWorkflow = "gG3SsUPtcqgtsbje" (error_flow) fires — Discord embed + an error-log row in Supabase proc_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>):

MarkerMeaningFix
rc=10run log not writable — /var/log/p24-infra-workers missing or not writable by claude-runnermkdir -p /var/log/p24-infra-workers && chown claude-runner … on bms-4
rc=11/opt/gmail-tools missingRe-run the p24-infra#4721 clone step
rc=12client_id/client_secret/refresh_token missing or unreadable under $HOME/.gmail-mcpRe-run the #4721 credential placement
rc=13Google rejected the OAuth refresh token — error name quotedRe-authenticate (see Blocker 3)
rc=14Claude usage limit hit mid-run (matched in the transcript’s last 5 lines)Wait for quota reset
rc=15token endpoint unreachable (DNS/egress/timeout) — not an expired grantCheck bms-4 egress to oauth2.googleapis.com
rc=16empty prompt after base64 decodeClient sent no usable text; retry with content
rc=124agent exceeded the 30-minute budgetInspect the run log; consider raising timeout
rc=1claude exited non-zero for anything else — claude’s real code is quotedRead the run log

rc=10rc=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):

ControlImplementation
Not world-readablethe 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-4the 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 n8nthe 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-ssh credential,
  • the gmail bot token already in secrets/gmail-tools.env.sops (GMAIL_TOOLS_TELEGRAM_BOT_TOKEN), which must be present in the n8n gmail-worker-bot telegram credential (Blocker 1).

Compliance — EU AI Act

Registered at creation (not activation), per CLAUDE.md §Compliance:

RecordValue
dev_r_ai_systemssystem_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_statuscompliant
dev_r_servicesn8n-bms4/gmail-tools-telegram-bot (child element of the gmail-tools service row)
Reproducible from the reposupabase/migrations/20260801120000_gmail_telegram_bot_ai_system_4819.sql
Narrativedocs/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 --allowedTools from day one, so the agent’s own tool surface is a fixed allowlist of gmail-tools scripts — no bare Bash, no Write, no arbitrary node -e. It cannot shell out to read the on-disk vps_root_key or GitHub PAT in claude-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.id allowlist) 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 mirrors
  • docs/telegram-claude-bot-operations.md — the single-authorized-user Telegram auth pattern reused here
  • docs/playbooks/n8n/n8n-ssh-worker-pattern.md — the SSH → claude -p pattern
  • docs/gmail-tools-operations.md — the parent gmail-tools service (clone, SOPS, credentials)
  • docs/eu-ai-act-compliance.md · docs/ai-risk-data-governance.md Part C — AI-system registration