Workflow Architecture · n8n Social Posting Agent

Building a headless social media engine with human-in-the-loop Telegram control

Solo build In production n8n Workflow Engine Telegram Bot API OpenAI gpt-4o-mini Blotato API Google Sheets

Managing social media distribution across seven different platforms is a masterclass in friction. TikTok and X require snappy, compact text under strict character limits; LinkedIn and Instagram reward structured, long-form storytelling; YouTube Shorts demands curiosity-driven, punchy titles to capture click-through rates. Attempting to manage this manually results in hours lost to copy-pasting and formatting.

Most automation tools force a binary choice: either pay for expensive, rigid SaaS scheduling suites, or build headless scripts that broadcast content blindly without manual quality control. The posting agent solves this by decoupling scheduling from publishing. It uses Google Sheets as a stateful ledger, LangChain for real-time content adaptation, and a dual-workflow Telegram architecture that puts an interactive approval gate directly in your chat app.

7
concurrent target platforms
0
custom web dashboards built
15m
automated cron polling cycle
100%
pre-publish human verification

The architecture — stateful orchestration without a database

Instead of standing up a dedicated PostgreSQL instance and a custom React frontend just to manage content queues, the posting agent leverages Google Sheets as an accessible, multi-user CRUD dashboard. Editors drop video links, draft captions, and select target platforms using simple checkboxes.

n8n Social Posting Agent Workflow Architecture
Workflow architecture showing the dual-workflow setup (polling, Telegram approval loop, and multi-platform publishing)

An automated cron trigger polls the sheet every 15 minutes, filtering specifically for rows where Status == 'Ready To Post' and the timestamp has matured. Once picked up, the workflow transitions the row state to prevent duplicate processing and writes critical execution metadata—specifically the instance Exec_ID and the asynchronous Resume_URL—back into the sheet.

// Filter By Schedule (n8n Code Node)
const items = $input.all();
const now = new Date();

const valid = items.filter(item => {
  const scheduledTime = item.json.Scheduled_At;
  if (!scheduledTime || scheduledTime.toString().trim() === '') return true;
  return new Date(scheduledTime) <= now;
});

if (!valid.length) return [];
return [valid[0]]; // Process strictly one row per execution tick to guarantee idempotency

Storing the Resume_URL directly in the spreadsheet ledger transforms a static row into an addressable state machine. External systems can inspect the sheet, locate the webhook endpoint for a paused post, and wake up sleeping execution threads on demand.

Human-in-the-loop control via asynchronous Telegram webhooks

True automation requires knowing when not to automate. Rather than broadcasting unverified AI-generated content, the Main Workflow halts execution immediately after preparing the content payloads. It constructs a rich markdown preview of the captions, platform targets, and AI-optimized titles, pushes it to an authorized Telegram chat, and enters an asynchronous Wait Approval state.

To handle interactions without polling or timeouts, a second decoupled workflow—the **Telegram Listener**—acts as a persistent command gateway:

1
Secure Command ParsingThe listener intercepts incoming Telegram messages, verifying that the chatId matches the admin authorization whitelist before processing regex command strings.
2
Ledger Lookup via Execution IDCommands like /edit_title_abc123 New Title extract the alphanumeric execId, performing a lookup against the Google Sheet to locate the exact row currently paused in execution.
3
Scratchpad Draft UpdatesIf an edit command is detected, the routing switch writes the payload to dedicated temporary draft columns (e.g., Title_Draft, Caption_Long_Draft) and confirms the save via chat.
4
Asynchronous Wake-Up CallWhen the admin issues `/approve_[execId]`, the listener reads the final draft columns and dispatches an HTTP POST request containing the consolidated payload directly to the paused workflow's Resume_URL.
// Command Parser — Router & Security Layer
const ADMIN_CHAT_ID = 'YOUR_TELEGRAM_CHAT_ID';
const msg = $input.first().json;
const chatId = String(msg.message?.chat?.id || '');
const text   = (msg.message?.text || '').trim();

if (chatId !== ADMIN_CHAT_ID) return []; // Drop unauthorized traffic silently

// Match pattern: /command_execId optional_payload
const re = /^\/(approve|reject|edit_title|edit_long|edit_short)_([a-zA-Z0-9]+)(?:\s+([\s\S]+))?$/;
const match = text.match(re);

if (!match) return [{ json: { command: 'unknown', execId: '', payload: '', chatId } }];

return [{ json: {
  command: match[1],        // approve | reject | edit_title | edit_long | edit_short
  execId:  match[2],        // Unique workflow execution identifier
  payload: (match[3] || '').trim(),
  chatId
}}];

Content adaptation & multi-platform fan-out

Once the wake-up webhook fires, the Main Workflow resumes execution. If the action was a rejection, it marks the ledger as Rejected and terminates cleanly. If approved, it dynamically overwrites the baseline content with any custom drafts submitted during the review pause, clears the scratchpad columns, and begins media syndication.

To satisfy platform-specific formatting rules without creating duplicate database records, content adaptation happens on the fly during data preparation:

Engineering War Stories: what broke and how it was fixed

Upgrade 01 — Shipped

Eliminating webhook race conditions using scratchpad draft columns

In the initial prototype, when an admin sent an edit command via Telegram, the listener attempted to directly update the primary content columns (e.g., Title, Caption_Long) in the sheet while the main workflow was suspended. However, if an admin sent two rapid edits followed immediately by `/approve`, the main workflow would occasionally resume and read the sheet before Google's API had fully committed the second write operation, publishing stale content.

I resolved this by decoupling real-time edits from permanent records. The Telegram Listener now writes exclusively to isolated draft columns (Title_Draft, Caption_Long_Draft). When `/approve` is triggered, the listener reads these draft values and bundles them directly into the JSON body of the resume POST webhook. The Main Workflow relies entirely on the webhook payload to apply edits in memory, completely bypassing the need for a synchronous, pre-publish sheet read.

Takeaway: Never rely on external database read-after-write consistency to pass state across concurrent asynchronous workflows. When waking up a suspended thread, inject the modified state directly into the resume payload.
Upgrade 02 — Shipped

Isolating platform failures to prevent cascade crashes during syndication

Social media APIs are notoriously unstable. Early in testing, a temporary token expiration on the X (Twitter) API would throw an unhandled exception during the fan-out phase. Because the publishing nodes were executed sequentially without error containment, an X API failure would immediately abort the entire execution thread, preventing the video from being published to YouTube or LinkedIn even though those credentials were perfectly valid.

I restructured the fan-out layer using isolated conditional branches paired with Blotato nodes configured with explicit error tolerance (onError: "continueErrorOutput" and automated 3x retry policies with 30-second exponential backoffs). Each platform branch now operates as a closed loop that writes its own localized status back to the sheet (e.g., Instagram_Log: "✅ Success" vs X_Log: "❌ Failed") and triggers a targeted Telegram alert if all retries fail, allowing the rest of the syndication matrix to finish uninterrupted.

Takeaway: In multi-tenant or multi-platform distribution architectures, treat every external integration as a guaranteed failure point. Isolate execution branches so partial outages degrade gracefully rather than causing system-wide aborts.
Upgrade 03 — Planned

Replacing cron polling with event-driven database triggers

While polling a Google Sheet on a 15-minute interval is highly resilient and requires zero infrastructure maintenance, it introduces an artificial latency floor. A video scheduled for 2:01 PM will sit idle in the ledger until the cron scheduler triggers at 2:15 PM.

The planned architectural upgrade involves migrating the primary ledger from Google Sheets to a Supabase PostgreSQL instance. By leveraging Supabase Database Webhooks, the system will listen for INSERT or UPDATE events where status == 'Ready To Post', dispatching the execution payload immediately to the n8n webhook gateway. This removes the cron scheduler entirely, reducing publish latency from up to 15 minutes down to sub-second real-time execution while cutting background compute cycles by over 90%.

Takeaway: Polling is an excellent starting point for stateless prototypes, but event-driven architectures are mandatory for time-sensitive production scaling. Build with clean interfaces so storage layers can be swapped without rewriting business logic.