DEV Community

Cover image for My n8n agent rewrote the same 7-task to-do list 4 times until I stopped asking for markdown
Lars Winstand
Lars Winstand

Posted on • Originally published at standardcompute.com

My n8n agent rewrote the same 7-task to-do list 4 times until I stopped asking for markdown

My n8n agent rewrote the same 7-task to-do list 4 times until I stopped asking for markdown

My n8n agent rewrote the same seven-item to-do list four times, dropped two checkboxes, and then marked the wrong task complete.

The bug was not reasoning.

The bug was markdown.

I had done what a lot of us do on autopilot: ask the model for a nice markdown task list because it looks readable in logs and GitHub-style checkboxes feel familiar.

That worked exactly once.

Then retries happened.

  • one retry changed indentation
  • another changed - [ ] to * [ ]
  • a later pass merged two tasks into one line because it decided that was “cleaner”
  • one run preserved the list visually but lost the actual task identity

To me, the output still looked fine.

To the workflow, it was garbage.

While digging into better formats, I found a thread on r/openclaw about markdown files for to-do management: https://reddit.com/r/openclaw/comments/1ueo4mm/markdown_files_for_todo_list/

That discussion hit the exact issue: markdown feels universal right up until an agent has to update it repeatedly without breaking anything.

The fix was boring and very effective:

Stop optimizing for readability.
Start optimizing for survival.

Markdown is for humans.
Schemas are for agents.

The actual failure mode

A markdown to-do list looks structured.

It is not actually structured.

That distinction matters once GPT-5.4, Claude Opus 4.6, or gpt-4o-2024-08-06 has to:

  1. read the list
  2. preserve it
  3. update one item
  4. return it
  5. do that again 10 times in a loop

That is where things drift.

Common failure patterns:

  • checkbox syntax changes
  • nesting changes
  • task IDs disappear because markdown never had real IDs
  • completed items move sections
  • a parser treats one wrapped line as two tasks
  • retries multiply because one malformed list poisons the next step

That last part is the expensive one.

Flaky formatting creates retry storms.
Retry storms turn a cheap-looking workflow into an operational headache.

If you run automations all day, output reliability matters almost as much as model quality.

That is also where predictable API pricing starts to matter. If your agents are retrying because of formatting drift, per-token billing gets annoying fast. Flat-cost usage is a lot easier to live with when workflows are noisy, iterative, and always on.

Why GitHub task lists are a bad automation contract

Because they are a rendering convention, not a durable machine interface.

GitHub task lists are part of GitHub Flavored Markdown, not core CommonMark.

That is fine if your goal is: render checkboxes on GitHub.

It is not fine if your goal is: pass stable task state across n8n, Make, Zapier, OpenClaw, or a custom agent loop.

A human sees this:

  • [ ] Draft outreach email
  • [x] Pull leads from Apollo
  • [ ] Review CRM sync

An agent sees a bunch of unanswered questions:

  • Is the dash required?
  • Can ordered lists count too?
  • Is nesting semantic or just visual?
  • If the task text changes, is it still the same task?
  • If one task wraps across lines, is that one task or two?

Once you start inventing your own answers, you are not really using markdown anymore.

You are building a fragile custom protocol disguised as markdown.

I tried JSON mode next. Better, but still not enough

The next obvious move was valid JSON.

That helped.

At least n8n stopped choking on checkboxes.

But JSON mode is not the same as schema enforcement.

It can give you valid JSON while still drifting structurally.

Typical failure cases:

  • due_date comes back as "tomorrow" instead of an ISO string
  • priority is "high" in one run and 3 in another
  • completed is missing entirely
  • subtasks is a string instead of an array

For a chat UI, whatever.

For an agent loop, this is where you start writing cleanup code around every step.

Here is the kind of request that looks reasonable but still leaves too much room for drift:

{
  "model": "gpt-4o-2024-08-06",
  "messages": [
    {
      "role": "system",
      "content": "Return a JSON object with a task list."
    },
    {
      "role": "user",
      "content": "Create tasks for launching a weekly customer report automation."
    }
  ],
  "response_format": { "type": "json_object" }
}
Enter fullscreen mode Exit fullscreen mode

That is better than markdown.

It is still not a contract.

What fixed it: strict schema output

What finally worked was using a strict schema.

Once I stopped asking for “a nice task list” and started asking for “an object that must match this schema,” the workflow got boring in the best way.

Here is a minimal task schema:

{
  "type": "object",
  "properties": {
    "tasks": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "title": { "type": "string" },
          "completed": { "type": "boolean" },
          "priority": { "type": "integer", "minimum": 1, "maximum": 5 },
          "due_date": { "type": ["string", "null"] }
        },
        "required": ["id", "title", "completed", "priority", "due_date"],
        "additionalProperties": false
      }
    }
  },
  "required": ["tasks"],
  "additionalProperties": false
}
Enter fullscreen mode Exit fullscreen mode

And here is an OpenAI-compatible request shape that is a lot safer than markdown:

{
  "model": "gpt-4o-2024-08-06",
  "messages": [
    {
      "role": "system",
      "content": "Generate a task list for an automation workflow."
    },
    {
      "role": "user",
      "content": "Plan the steps for onboarding a new B2B customer into HubSpot, Slack, and Notion."
    }
  ],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "task_list",
      "schema": {
        "type": "object",
        "properties": {
          "tasks": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string" },
                "title": { "type": "string" },
                "completed": { "type": "boolean" },
                "priority": { "type": "integer" }
              },
              "required": ["id", "title", "completed", "priority"],
              "additionalProperties": false
            }
          }
        },
        "required": ["tasks"],
        "additionalProperties": false
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That is the difference between:

  • “format this nicely”
  • “return data my workflow can trust”

A practical n8n example

Here is the pattern that kept breaking for me:

  1. Slack trigger receives a request
  2. HTTP Request node calls an LLM
  3. Code node parses the task list
  4. Notion or ClickUp tasks get created
  5. Another agent step updates state later

If step 2 returns markdown, step 3 usually becomes regex hell.

You end up doing stuff like this:

const text = $json.output;
const lines = text.split("\n");

const tasks = lines
  .filter(line => /^[-*]\s\[[ x]\]/.test(line))
  .map((line, index) => ({
    id: `task-${index + 1}`,
    completed: line.includes("[x]"),
    title: line.replace(/^[-*]\s\[[ x]\]\s*/, "").trim()
  }));

return [{ tasks }];
Enter fullscreen mode Exit fullscreen mode

This works until it does not.

Now compare that with schema-validated JSON:

const tasks = $json.tasks;

return tasks.map(task => ({
  json: {
    id: task.id,
    title: task.title,
    completed: task.completed,
    priority: task.priority
  }
}));
Enter fullscreen mode Exit fullscreen mode

That version is boring.

Boring is exactly what you want in automation.

Example: calling an OpenAI-compatible API from Node.js

If your provider supports OpenAI-compatible structured output, the code is straightforward.

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL
});

const response = await client.chat.completions.create({
  model: "gpt-4o-2024-08-06",
  messages: [
    {
      role: "system",
      content: "Generate a task list for an automation workflow."
    },
    {
      role: "user",
      content: "Plan the steps for onboarding a new B2B customer into HubSpot, Slack, and Notion."
    }
  ],
  response_format: {
    type: "json_schema",
    json_schema: {
      name: "task_list",
      schema: {
        type: "object",
        properties: {
          tasks: {
            type: "array",
            items: {
              type: "object",
              properties: {
                id: { type: "string" },
                title: { type: "string" },
                completed: { type: "boolean" },
                priority: { type: "integer" }
              },
              required: ["id", "title", "completed", "priority"],
              additionalProperties: false
            }
          }
        },
        required: ["tasks"],
        additionalProperties: false
      }
    }
  }
});

const content = response.choices[0].message.content;
const data = JSON.parse(content);
console.log(data.tasks);
Enter fullscreen mode Exit fullscreen mode

If you are using a drop-in OpenAI-compatible endpoint, this pattern usually ports cleanly.

That matters if you are testing multiple providers or routing across models without rewriting your app.

Same idea from curl

curl https://api.example.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o-2024-08-06",
    "messages": [
      {
        "role": "system",
        "content": "Generate a task list for an automation workflow."
      },
      {
        "role": "user",
        "content": "Plan the steps for onboarding a new B2B customer into HubSpot, Slack, and Notion."
      }
    ],
    "response_format": {
      "type": "json_schema",
      "json_schema": {
        "name": "task_list",
        "schema": {
          "type": "object",
          "properties": {
            "tasks": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "id": { "type": "string" },
                  "title": { "type": "string" },
                  "completed": { "type": "boolean" },
                  "priority": { "type": "integer" }
                },
                "required": ["id", "title", "completed", "priority"],
                "additionalProperties": false
              }
            }
          },
          "required": ["tasks"],
          "additionalProperties": false
        }
      }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

How this plays out in n8n, Make, Zapier, and OpenClaw

The practical win is not elegance.

It is fewer broken runs.

In n8n

A solid pattern looks like this:

  • Slack Trigger or Webhook node
  • HTTP Request node to an OpenAI-compatible endpoint
  • Set or Code node to map tasks[]
  • Notion, Linear, ClickUp, Airtable, or Postgres node downstream
  • If node branches on completed === false

No regex parser.
No markdown cleanup node.
No weird branch because a checkbox changed shape.

In Make or Zapier

The same rule applies, maybe even more strongly.

The more no-code modules you chain together, the more painful loose formatting becomes. Every downstream module assumes the previous one handed over something stable.

If your LLM output is “mostly parseable,” your scenario is already on borrowed time.

In OpenClaw or custom agent loops

Repeated read-modify-write cycles are exactly where markdown breaks.

If the same task list gets rewritten five or ten times, use stable IDs and schema validation. Otherwise you are effectively asking the model to preserve formatting conventions that were never designed to carry state.

When to use markdown, JSON Schema, or a real task API

Here is the rule I use now:

Use case Best format
Human-readable notes in logs or docs Markdown
LLM output that feeds another workflow step JSON Schema
Shared task state, reminders, filters, attachments Real task API
File-based simplicity with narrow syntax todo.txt

If task state actually matters, skip text formats and use a real task system.

Vikunja is a good example if you want an API-backed task manager instead of pretending a markdown file is a database.

If you truly need plain files, todo.txt is still better than markdown for automation because one line equals one task and the syntax is intentionally narrow.

The bigger lesson

I did not fix my agent by making it smarter.

I fixed it by giving it a format that was harder to improvise.

A lot of “agent reasoning failures” are not reasoning failures at all.

They are contract failures.

The agent understands the work.
The workflow does not trust the output.

So my rule now is simple:

  • use markdown for humans
  • use JSON Schema for agents
  • use a real task API when task state matters
  • do not pretend GitHub task lists are a durable automation interface

If your agent keeps rewriting the to-do list, stop tuning the prompt for a minute.
Stop blaming GPT-5.4 or Claude Opus 4.6.

The problem might just be the contract you chose.

One more practical note on cost

This kind of bug gets worse when agents run continuously.

Every malformed output means retries, repair steps, validation passes, and extra calls. If you are paying per token, formatting mistakes turn into real spend surprisingly fast.

That is one reason I like OpenAI-compatible infrastructure that is easy to swap under existing SDKs and workflows. If you are running n8n, Make, Zapier, OpenClaw, or custom agents all day, predictable flat-cost compute is a much nicer setup than watching token usage every time a workflow gets chatty.

That tradeoff becomes very obvious once you stop thinking about one prompt and start thinking about 24/7 automation.

If you have been shipping markdown between agent steps, I would change that before touching anything else.

Top comments (0)