-
Notifications
You must be signed in to change notification settings - Fork 61
(Not an Issue) How I made a similar one: #4
Description
Homelab Telegram AI Agent (n8n) — Build Log & Documentation
Date: 2025-12-27
User: JordanDtr3133
Stack: n8n + Telegram + Postgres + n8n AI Agent + n8n Chat Memory
Homelab services context: Docker, Technitium DNS, Crafty Controller, Portainer
1. Goal
Create a secure, session-based Telegram “AI agent” workflow in n8n that:
- Receives Telegram messages (only from a whitelisted user)
- Routes special commands:
/start→ start a new session/finish→ end the active session/continue→ reopen the most recent closed session
- For normal messages:
- Only responds if there is an active session
- Uses persistent memory so the conversation continues across messages
- Uses Postgres to track sessions and uses n8n’s built-in AI Agent + Chat Memory for the LLM/memory layer
2. Key Architecture Decision (What “finished the workflow”)
Event-driven, not a “loop”
Instead of trying to “look for messages” inside a loop, we used a robust model:
- Every Telegram message triggers the workflow
- The workflow decides what to do based on session state in Postgres
This avoids long-running executions and makes the system more reliable and easier to debug.
3. Session Model Implemented
We implemented explicit sessions tied to a Telegram chat:
- Each Telegram chat (private chat) can have multiple sessions over time.
- At most one session is open at a time (per chat).
- Sessions move between:
open→ active conversationclosed→ archived conversation
Commands Behavior
/start- If a session is open → reply “already active”
- Else → create a new open session and reply with the session UUID
/finish- If a session is open → close it and confirm
- Else → reply “no active session”
/continue- If a session is open → reply “already active”
- Else if a closed session exists → reopen the most recent closed session
- Else → reply “no previous session”
4. Database: What we created and why
Problem encountered
When running the “Load Session State” query, Postgres returned:
relation "agent_sessions" does not exist
This meant the table wasn’t created yet.
Fix
We created a session table to store chat sessions.
Table (recommended)
create extension if not exists pgcrypto;
create table if not exists agent_sessions (
id bigserial primary key,
chat_id bigint not null,
session_id uuid not null unique,
status text not null check (status in ('open','closed')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_agent_sessions_chat_status_updated
on agent_sessions (chat_id, status, updated_at desc);Notes:
pgcryptois used forgen_random_uuid().- If
pgcryptois not available, UUID can be generated in n8n and inserted as a value.
5. Telegram “undefined” / literal expression issue
Symptom
Telegram messages showed the text as:
"text": "{{$json.message.text || \"\"}}"That happened because an n8n expression was sent as literal text (not evaluated), then that literal was copied and re-sent in Telegram.
Fix
In Telegram “Send Message” nodes:
- Ensure the message field is in expression mode (green), or use “Add Expression”
- Or use a static string
6. Workflow Node Chain (Final)
Node 1 — Telegram Trigger
- Fires on every new Telegram message
Node 2 — Whitelist check (IF)
- Compares
message.from.idagainst a single allowed Telegram ID - Blocks all other users
Node 3 — Postgres: Load Session State
Loads:
- The newest open session for this chat (if any)
- The most recent closed session for this chat (if any)
Query:
select
(select row_to_json(s)
from agent_sessions s
where s.chat_id = {{$json.message.chat.id}} and s.status = 'open'
order by s.updated_at desc
limit 1) as open_session,
(select row_to_json(s)
from agent_sessions s
where s.chat_id = {{$json.message.chat.id}} and s.status = 'closed'
order by s.updated_at desc
limit 1) as last_closed_session;Node 4 — Switch: Command Router
We confirmed Switch is best used as a “multi-check IF”.
We set:
- Value expression:
{{ ($json.message.text ?? "").trim().toLowerCase() }}Rules:
- equals
/start - equals
/finish - equals
/continue
Default handling:
- Enabled Extra Output / Fallback output so anything else goes to the normal-message branch.
Branch A — /start
- IF open_session exists:
- Telegram reply: “already active”
- Else:
- Postgres insert new session:
insert into agent_sessions (chat_id, session_id, status) values ({{$json.message.chat.id}}, gen_random_uuid(), 'open') returning *;
- Telegram reply: “Started a new session: ”
- Postgres insert new session:
Branch B — /finish
- IF open_session exists:
- Postgres close:
update agent_sessions set status = 'closed', updated_at = now() where chat_id = {{$json.message.chat.id}} and status = 'open' returning *;
- Telegram reply: “Finished session: ”
- Postgres close:
- Else:
- Telegram reply: “No active session…”
Branch C — /continue
- IF open_session exists:
- Telegram reply: “already active”
- Else IF last_closed_session exists:
- Postgres reopen:
update agent_sessions set status = 'open', updated_at = now() where id = ( select id from agent_sessions where chat_id = {{$json.message.chat.id}} and status = 'closed' order by updated_at desc limit 1 ) returning *;
- Telegram reply: “Continuing session: ”
- Postgres reopen:
- Else:
- Telegram reply: “No previous session found…”
Branch D — Default (normal messages)
- IF open_session exists:
- AI Agent runs
- Reply with model output
- Update session timestamp
- Else:
- Prompt user to
/startor/continue
- Prompt user to
Chat Memory key (critical)
Chat Memory uses a stable key per session:
{{ $json.message.chat.id + ":" + $json.open_session.session_id }}This ensures:
- Messages within a session share memory
/continueresumes the right conversation history
Post-response “touch updated_at”
Query (we confirmed you can use trigger references if desired):
update agent_sessions
set updated_at = now()
where chat_id = {{ $('Telegram Trigger').item.json.message.chat.id }}
and status = 'open';7. System Prompt Added (Tailored to your stack)
We added a system prompt for a homelab assistant oriented around:
- Docker
- Technitium DNS
- Crafty Controller
- Portainer
Key properties:
- Telegram-friendly format
- Safety gating for destructive actions
- Diagnostic-first troubleshooting
- No secret leakage
(Stored in the AI Agent node’s System message.)
8. Disaster Recovery Note (n8n volume deletion)
Halfway through, the n8n volume was deleted and the workflow had to be recreated.
Implications:
- Any n8n internal state and local config stored in the volume was lost.
- Postgres-backed session storage and memory can survive if Postgres was on a separate persistent volume.
Recommended mitigation:
- Back up Docker volumes (or bind-mount to a protected location)
- Export n8n workflows regularly (JSON export)
- Store session/memory in Postgres (already done)
- Consider GitOps for n8n workflow JSON
9. Final Validation Tests Performed
-
Switch routing test
/start→ Start branch/finish→ Finish branch/continue→ Continue branch- Any other message → Default branch (Extra Output)
-
Session behavior
/startcreates new session if none open/startagain reports already-active session/finishcloses open session/finishagain reports no open session/continuereopens last closed session- Normal messages require an open session
-
Memory continuity
- Normal messages in same session retain context via memory key:
chatId:sessionId
- Normal messages in same session retain context via memory key:
10. Current State
- Workflow is working end-to-end
- Commands are routed correctly via Switch fallback/extra output
- Sessions are persisted in Postgres
- AI Agent uses per-session memory
- System prompt is tailored to your homelab stack
11. Optional Next Improvements
- Add dedupe by storing
update_idto prevent repeated processing - Add session expiry (auto-close after X hours of inactivity)
- Add
/statusto print session id + timestamps - Add session picker for
/continue(choose from last N closed sessions) - Add “confirm before action” pattern if you later connect tools (e.g., Docker actions)