cd /news/ai-agents/building-a-human-in-the-loop-autonom… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-102595] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Building a Human-in-the-Loop Autonomous Coding Agent with n8n and Telegram

A developer built a human-in-the-loop autonomous coding agent that integrates Telegram, Cloudflare Tunnel, n8n, an Express.js runner, and Codex to enable remote coding with approval-based control. The system runs locally on the developer's machine, using Cloudflare Tunnel to bridge Telegram webhooks to the local n8n instance, and escalates sensitive actions to the developer's phone for approval. The architecture allows the agent to work independently on routine tasks while requiring human consent for operations outside granted permissions.

read13 min views5 publishedAug 19, 2026

What if I could send a coding task from my phone, put the phone back in my pocket, and let an AI agent work on my development machine?

And what if, whenever the agent wanted to do something that required my permission, it could simply message me on Telegram?

That was the idea behind a small automation project I recently built.

The final architecture combines Telegram, Cloudflare Tunnel, n8n, a lightweight Express.js runner, and Codex to create a remote, human-in-the-loop autonomous coding workflow.

The interesting part is that almost everything still runs on my own development machine.

I didn't deploy n8n or my Codex runner to a public server just to make Telegram communication possible. Instead, I use Cloudflare Tunnel as the bridge between the public internet and my local n8n instance.

The result looks roughly like this:

                    INTERNET
                        β”‚
                        β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Telegram   │──▢│  Cloudflare   β”‚
β”‚    Bot      β”‚   β”‚    Tunnel     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
                          β”‚ secure tunnel
                          β–Ό
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚   Local n8n    β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚
                         β–Ό
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚ Express Runner β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚
                         β–Ό
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚     Codex      β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚
                         β–Ό
                    Local Repo

And when Codex requires approval:

Codex
  β”‚
  β”‚ approval required
  β–Ό
Express Runner
  β”‚
  β–Ό
n8n
  β”‚
  β–Ό
Telegram
  β”‚
  β”‚ Approve / Reject
  β–Ό
n8n
  β”‚
  β–Ό
Express
  β”‚
  β–Ό
Codex continues

This article explains how I built it and, more importantly, some of the architectural problems that appeared once I tried turning a coding agent into something I could actually operate remotely.

AI coding agents are already capable of doing much more than generating code snippets.

They can inspect a repository, modify multiple files, execute shell commands, run tests, inspect failures, fix their implementation, and repeat the process.

But there was still one inconvenience in my workflow:

I had to be sitting at my development machine to initiate and supervise the work.

I wanted something closer to this:

Me, from Telegram:

"resume-web:
Add rate limiting to the API
and add tests."

        ↓

Codex works on my PC

        ↓

Telegram:

"Task completed.
12 tests passed."

All from my phone.

But I had another requirement.

I didn't want to achieve remote coding by simply giving an AI agent unrestricted control over my machine.

If the agent wants to perform something outside the permissions I've granted, I still want to make that decision.

So the real goal became:

Build an autonomous coding workflow where normal development work can proceed independently, while sensitive actions are escalated back to my phone for approval.

I think human-in-the-loop autonomous coding is the best description for it.

The first question was how I wanted to interact with the system.

I could have built a web dashboard.

But then I'd have to build authentication, a frontend, notifications, mobile responsiveness, task history, and several other things unrelated to the actual experiment.

Telegram already gives me most of that.

It provides:

More importantly:

it's already on my phone.

So Telegram became my remote control.

Telegram = Remote UI

There was an immediate problem.

My n8n instance runs locally.

Something like:

http://localhost:5678

That's perfectly fine when I'm sitting at my computer.

But Telegram obviously cannot send webhook requests to:

localhost:5678

localhost

only exists from the perspective of my machine.

I needed:

Public Internet
      ↓
Public HTTPS endpoint
      ↓
My local n8n

One solution would have been deploying n8n somewhere publicly accessible.

But I specifically wanted to keep this automation running on my own machine.

That's where Cloudflare Tunnel entered the architecture.

Cloudflare Tunnel lets a locally running service become reachable through a public hostname without exposing the machine itself directly.

Instead of opening a router port like:

Internet
   ↓
Public IP
   ↓
Port forwarding
   ↓
My PC

I run cloudflared

locally.

The connection is initiated outbound from my machine toward Cloudflare.

Conceptually:

My PC
 β”‚
 β”‚ outbound connection
 β–Ό
Cloudflare
 β”‚
 β”‚ public HTTPS
 β–Ό
Internet

Then Cloudflare can map a public hostname to my local n8n service.

For example:

automation.example.com
          β”‚
          β–Ό
  Cloudflare Tunnel
          β”‚
          β–Ό
http://localhost:5678

This means Telegram can reach a public HTTPS webhook while n8n itself continues running locally.

So my webhook path can conceptually become:

https://automation.example.com/webhook/telegram

while the actual service receiving it is still:

http://localhost:5678

I don't have to expose n8n's port directly to the internet.

I didn't want my architecture to become:

Telegram
   ↓
MY_PUBLIC_IP:5678
   ↓
n8n

That would require exposing an inbound service from my development network.

Cloudflare Tunnel gives me a cleaner model:

                    Public Internet
                           β”‚
                           β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚ Cloudflare  β”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                     secure tunnel
                           β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
                    β”‚ cloudflared β”‚
                    β”‚   My PC     β”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                           β–Ό
                    localhost:5678
                           β”‚
                           β–Ό
                          n8n

The tunnel is initiated from the local machine rather than requiring inbound port forwarding.

For a personal automation project like this, that was exactly what I wanted.

One thing that's important to clarify is that Cloudflare isn't running my coding workflow.

It doesn't execute Codex.

It doesn't orchestrate the tasks.

And it doesn't run n8n.

Its job in this architecture is much smaller:

Public ingress to local n8n

The actual execution still happens locally:

LOCAL DEVELOPMENT MACHINE

cloudflared
     β”‚
     β–Ό
    n8n
     β”‚
     β–Ό
Express.js
     β”‚
     β–Ό
Codex
     β”‚
     β–Ό
Repository

I like this separation because each component has a very specific responsibility.

Once Telegram can reach my local environment, I still need something to orchestrate the workflow.

For example:

Receive Telegram message
        ↓
Parse project + instruction
        ↓
Validate input
        ↓
Call Codex runner
        ↓
Wait for completion
        ↓
Format result
        ↓
Send Telegram response

That's where n8n fits.

My simplified workflow looks something like:

Telegram Trigger
       β”‚
       β–Ό
Parse Command
       β”‚
       β–Ό
HTTP Request
       β”‚
       β–Ό
Local Codex Runner
       β”‚
       β–Ό
Format Result
       β”‚
       β–Ό
Telegram Message

n8n isn't the coding agent.

It's the orchestrator.

I still needed something that could actually launch and communicate with Codex.

I built a lightweight Express.js service for that.

Conceptually, n8n calls:

POST /codex

with something like:

{
  "project": "resume-web",
  "task": "Add rate limiting to the API and add tests."
}

The runner maps a project alias to an actual local directory:

const projects = {
  "resume-web": "D:\\Projects\\resume-web"
};

This was an important design decision.

I don't allow Telegram to specify an arbitrary filesystem path.

Instead of:

{
  "path": "C:\\whatever\\someone\\wants"
}

the remote interface only knows:

resume-web
backend-api
personal-rag

The runner decides where those repositories actually exist.

So:

Telegram
   β”‚
   β”‚ "resume-web"
   β–Ό
Express
   β”‚
   β”‚ lookup
   β–Ό
D:\Projects\resume-web

That gives me a simple allowlist around what the remote system can operate on.

My original runner was surprisingly simple.

It essentially spawned Codex:

const child = spawn(
  "codex.cmd exec --sandbox workspace-write -",
  {
    cwd: projectPath,
    shell: true
  }
);

Then I collected stdout and stderr:

let stdout = "";
let stderr = "";

child.stdout.on("data", (data) => {
  stdout += data.toString();
});

child.stderr.on("data", (data) => {
  stderr += data.toString();
});

Finally:

child.on("close", (code) => {
  res.json({
    success: code === 0,
    output: stdout,
    error: stderr
  });
});

That produced:

Telegram
    ↓
Cloudflare
    ↓
n8n
    ↓
Express
    ↓
codex exec
    ↓
Repository
    ↓
Codex exits
    ↓
Express response
    ↓
n8n
    ↓
Telegram

And it worked.

I could literally send a coding instruction from my phone and receive the Codex result back on Telegram.

But there was still one major problem.

Giving an agent permission to edit code is one thing.

Giving it unrestricted access to everything on the machine is another.

I wanted Codex to work autonomously inside a controlled environment, but still ask me when it crossed a permission boundary.

Normally this is easy when you're sitting in front of the CLI:

Codex wants to perform an action.

Approve?

[Y] Yes
[N] No

But my Codex process is being triggered remotely.

That created the most interesting problem in the entire project:

How can Codex a task, tell Express that it needs approval, send that request all the way to my phone, and then continue the exact same task after I approve it?

My first runner treated:

child.on("close", ...)

as:

TASK FINISHED

That makes sense for a one-shot command.

But it becomes limiting once the agent needs an interactive lifecycle.

What I actually needed was:

Persistent Codex process

      β”‚
      β”œβ”€β”€ Turn
      β”‚    β”‚
      β”‚    β”œβ”€β”€ working
      β”‚    β”œβ”€β”€ working
      β”‚    β”œβ”€β”€ approval required
      β”‚    β”‚       ↓
      β”‚    β”‚      WAIT
      β”‚    β”‚       ↓
      β”‚    β”‚    approved
      β”‚    β”‚
      β”‚    β”œβ”€β”€ continue
      β”‚    β”‚
      β”‚    └── completed
      β”‚
      └── ready for next turn

That led me to using Codex App Server.

With an event-driven Codex integration, Express can receive lifecycle messages while the task is still running.

An approval request is no longer equivalent to task completion.

Instead:

approval request
      β‰ 
task completed

Codex can effectively tell the runner:

"I'm still working on this turn,
but I need a human decision before
I can continue."

The runner stores that pending approval and triggers a dedicated n8n webhook.

This is probably my favorite part of the system.

Suppose Codex needs permission.

The request travels:

Codex
   ↓
Express Runner
   ↓
n8n
   ↓
Cloudflare Tunnel
   ↓
Telegram

On my phone I receive something like:

⚠️ Codex Approval Required

Project:
resume-web

Action:
<requested operation>

[ Approve ]   [ Reject ]

I press:

Approve

and the response travels back through the automation:

Telegram
   ↓
Cloudflare
   ↓
n8n
   ↓
Express Runner
   ↓
Codex

The important part is that this isn't a new coding task.

It's a response to the existing d Codex turn.

Codex receives the decision and continues exactly where it stopped.

There was one design decision from my original implementation that I intentionally kept.

The n8n request waits until Codex finishes.

Why?

Because it gives me an extremely simple final-result pipeline:

Codex finishes
      ↓
Express responds
      ↓
n8n immediately continues
      ↓
Telegram receives result

I don't need:

GET /task/status
GET /task/status
GET /task/status
GET /task/status

or another polling mechanism.

Express simply awaits the Codex turn.

Conceptually:

const result = await codexRunner.runTurn({
  project,
  task
});

res.json(result);

If approval never happens:

Task
 ↓
Codex
 ↓
Complete
 ↓
HTTP response

If approval happens:

Task
 ↓
Codex
 ↓
Approval required
 ↓
Telegram
 ↓
Approve
 ↓
Codex continues
 ↓
Complete
 ↓
HTTP response

From the original n8n workflow's perspective, both eventually produce the same result.

I also wanted to know how long Codex actually takes to perform tasks.

So my runner logs execution duration.

Originally:

{
  "project": "resume-web",
  "durationMs": 128421,
  "success": true
}

But remote approval introduced an interesting problem.

Suppose:

Codex works         2 minutes
I ignore Telegram   6 minutes
Codex continues     1 minute

Total wall-clock time:

9 minutes

But saying:

"Codex took 9 minutes"

wouldn't really be accurate.

So I separated:

{
  "durationMs": 540000,
  "codexActiveMs": 180000,
  "approvalWaitMs": 360000,
  "approvalCount": 1
}

This opens up some interesting profiling possibilities later:

Average task duration
Average Codex active time
Approval frequency
Human response time
Failure rate
Commands per task
Performance by project

After putting everything together, my system now looks like this:

                         INTERNET

                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚     Telegram      β”‚
                  β”‚       Bot         β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β”‚ HTTPS
                            β–Ό
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚    Cloudflare     β”‚
                  β”‚      Tunnel       β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                    outbound tunnel
                            β”‚
════════════════════════════β•ͺ══════════════════
                    LOCAL MACHINE
                            β”‚
                            β–Ό
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚        n8n        β”‚
                  β”‚   Orchestration   β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β”‚ localhost
                            β–Ό
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚  Express Runner   β”‚
                  β”‚                   β”‚
                  β”‚ β€’ project map     β”‚
                  β”‚ β€’ task lifecycle  β”‚
                  β”‚ β€’ approval state  β”‚
                  β”‚ β€’ profiling       β”‚
                  β”‚ β€’ logging         β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β”‚ JSON-RPC
                            β–Ό
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚ Codex App Server  β”‚
                  β”‚                   β”‚
                  β”‚ β€’ thread          β”‚
                  β”‚ β€’ turn            β”‚
                  β”‚ β€’ tools           β”‚
                  β”‚ β€’ approvals       β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β–Ό
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚ Local Repository  β”‚
                  β”‚                   β”‚
                  β”‚ Code / Git / Test β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each component has a very specific job:

Telegram
    β†’ Remote interface

Cloudflare Tunnel
    β†’ Public ingress to my local environment

n8n
    β†’ Workflow orchestration

Express.js
    β†’ Codex process + task management

Codex
    β†’ Autonomous coding agent

Git repository
    β†’ Actual development workspace

I think that separation is one of the reasons the project stayed relatively understandable.

I wouldn't describe this as fully autonomous software development.

I'm still responsible for deciding what gets built, reviewing important changes, controlling permissions, and deciding what eventually reaches production.

But once I provide a task, the implementation loop can happen independently:

Inspect
   ↓
Plan
   ↓
Modify
   ↓
Build
   ↓
Test
   ↓
Fail
   ↓
Debug
   ↓
Fix
   ↓
Retest
   ↓
Complete

And if the agent reaches something requiring my judgment:

Agent
  ↓
Human decision required
  ↓
Telegram
  ↓
Me

That's why I prefer the term:

Human-in-the-loop autonomous coding.

There is obviously a much easier solution to the approval problem:

Give the coding agent unrestricted access.

Then there are no approval interruptions.

But that's exactly what I didn't want.

Instead, the philosophy behind my setup is:

Normal development work
        ↓
     AUTOMATIC

Sensitive operation
        ↓
    ASK HUMAN

For example:

Read repository
        β†’ automatic

Edit workspace
        β†’ automatic

Run tests
        β†’ automatic

Inspect Git
        β†’ automatic

Sensitive command
        β†’ approval

Outside workspace
        β†’ approval

Network operation
        β†’ approval

Destructive operation
        β†’ approval

The goal isn't to remove myself from development.

It's to move myself:

FROM

execution loop

TO

decision loop

And I think that's a much more useful form of automation.

The current system already works, but there are several things I'd like to improve.

I want commands such as:

/status

to return something like:

Project: resume-web
Task: Add API rate limiting
Status: Running
Duration: 02:41
Approvals: 0

I'd also like:

/cancel

to interrupt an active Codex turn.

Another obvious improvement is task queues.

I don't necessarily want:

Task A ─┐
Task B ─┼─→ same repository
Task C β”€β”˜

all editing files simultaneously.

Instead:

resume-web

Task A β†’ RUNNING
Task B β†’ QUEUED
Task C β†’ QUEUED

Git branch isolation would also make the system much safer:

Telegram task
      ↓
Create branch
      ↓
Codex works
      ↓
Tests
      ↓
Return diff
      ↓
Human review
      ↓
Merge

And eventually, persistent state would help recover from crashes or restarts while approvals are pending.

At first, I thought this project would mostly be about AI.

It wasn't.

The most interesting problems ended up being orchestration problems:

How does Telegram reach localhost?

Who owns task state?

What does "completed" mean?

How does an agent ?

How do approvals travel between systems?

How does execution resume?

What happens if something crashes?

Which operations should be automatic?

Which operations require humans?

The model is only one part of an autonomous system.

The infrastructure around the model determines whether it can actually operate reliably.

Before building this, my workflow looked roughly like:

Open laptop
   ↓
Open terminal
   ↓
Navigate to repository
   ↓
Launch coding agent
   ↓
Give instruction
   ↓
Watch execution
   ↓
Approve action
   ↓
Wait
   ↓
Review

Now:

Take phone
   ↓
Open Telegram
   ↓
Send task
   ↓
Put phone away

Then, if necessary:

Telegram:

⚠️ Codex needs approval

[Approve] [Reject]

And eventually:

Telegram:

βœ… Task completed

My development machine is still doing the actual work.

I'm still responsible for the important decisions.

But I no longer need to sit in front of it while the agent performs every step.

And that's probably the biggest thing I learned from this project:

The next step for coding agents isn't necessarily removing developers. It's reducing how much of the execution loop requires a developer's continuous attention.

Sometimes the biggest improvement isn't another model.

It's building better infrastructure around the one you already have.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @telegram 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/building-a-human-in-…] indexed:0 read:13min 2026-08-19 Β· β€”