Skip to content

(Not an Issue) How I made a similar one: #4

@JordanDtr3133

Description

@JordanDtr3133

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 conversation
    • closed → 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:

  • pgcrypto is used for gen_random_uuid().
  • If pgcrypto is 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.id against 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: ”

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: ”
  • 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: ”
  • 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 /start or /continue

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
  • /continue resumes 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

  1. Switch routing test

    • /start → Start branch
    • /finish → Finish branch
    • /continue → Continue branch
    • Any other message → Default branch (Extra Output)
  2. Session behavior

    • /start creates new session if none open
    • /start again reports already-active session
    • /finish closes open session
    • /finish again reports no open session
    • /continue reopens last closed session
    • Normal messages require an open session
  3. Memory continuity

    • Normal messages in same session retain context via memory key:
      chatId:sessionId

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_id to prevent repeated processing
  • Add session expiry (auto-close after X hours of inactivity)
  • Add /status to 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions