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

> Source: <https://dev.to/anggbchtr/building-a-human-in-the-loop-autonomous-coding-agent-with-n8n-and-telegram-46gp>
> Published: 2026-08-19 07:37:50+00:00

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:

```
Cloudflare Tunnel
       =
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:

``` js
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:

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

Then I collected stdout and stderr:

``` js
let stdout = "";
let stderr = "";

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

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

Finally:

``` js
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 pause 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 paused 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:

``` js
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 pause?

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.
