Hello! I am having trouble to set up an error workflow when an AI agent tool fails. Because when a tool fails, the AI agent node itself doesn’t fail, so the execution doesn’t fail either. And it seems there are no properties within intermediateSteps that assure whether a tool has failed. Is there any workaround for this?
Hi @xmateusx14
I think this is a limitation of the AI Agent node
Error workflows in n8n are triggered only when a node fails, and the AI Agent node does not fail when a tool fails because tool errors are treated as part of the agent’s reasoning, not as execution errors.
As a solution i think you should handle it manullay , and you can do it with IF node or with code node ( try catch mechanism ) ,
for example , instead of letting tools fail, you can make them always return a structured JSON response like { "success": false, "error": "your error message" }, then let the AI Agent read this output and, after the agent, check the result with an IF node to detect failures and manually route the execution to an error branch
Hey @ayoub_ghozzi
It is really unfortunate n8n has such a limitation. But I’ve managed to think another way to catch tool errors. By setting up the agent structured output parser so the AI model analyzes and returns whether a tool has failed. Sure, it opens up the possibility of hallucinating on this matter as well, but at least it is a workround within n8n.
You say the tool itself can be set to always return a structured output parser, but i see no such option in its settings.
Hey everyone! I found a practical way to handle this.
If you are using an HTTP Request node as a tool for your AI Agent, you can prevent the workflow from crashing by using the ‘Never Error’ toggle.
Here is how to set it up:
-
In the HTTP Request node, go to the Options section
-
Add the Response option
-
Toggle ‘Never Error’ to ON
Normally, if a tool fails, n8n stops the entire execution. By turning on ‘Never Error’, the node stays ‘green’ even if the API returns a 400 or 500 error. The error message is then passed back to the AI Agent as a regular string.
The Agent can then ‘read’ the error (e.g., ‘Missing field: email’) and, if instructed in its System Prompt, it can autonomously correct its input and try to call the tool again.
Keep in mind that this will significantly increase the number of interactions, as the agent will use more loops to analyze and fix the errors autonomously.
@Scribble’s “Never Error” tip is solid for HTTP Request tools. for other tool types (Code node, sub-workflow tools, etc.) a similar pattern works: wrap the tool logic in a try/catch inside a Code node and return a structured error object instead of throwing:
js
try {
// your tool logic here
return [{ json: { success: true, result: ... } }]
} catch (e) {
return [{ json: { success: false, error: e.message } }]
}
```
then in your system prompt tell the agent: "if a tool returns `success: false`, read the `error` field and either retry with corrected input or inform the user what went wrong."
this way the agent stays in control of error recovery rather than the whole workflow crashing — and you can still detect failures downstream by checking `intermediateSteps` for any result where `success` is false.
This is a classic ‘silent failure’ problem with AI agents. @Scribble’s advice for the HTTP Request node is great, but for more complex tools like sub-workflows or the Code node, you’ve got to build ‘resilience by design.’
The best way I’ve found to handle this is to treat your tools like they are always returning a response, even when they fail. Wrap your sub-workflow or Code node logic so it always returns a structured object:
Then—and this is the key—in your System Prompt, explicitly tell the agent:
‘If a tool returns { success: false }, do not stop. Read the error field, attempt to fix the input (e.g., correct a date format or missing field), and retry the tool. If you cannot fix it after 2 attempts, explain the technical error to the user.’
This keeps the agent in the driver’s seat. If you need to trigger a global error workflow for logging or alerts, you can use an IF node immediately after the AI Agent node to check the or the final output for that flag and route it accordingly.
Ugh, my bash-brain just ate the code block in that last reply. Let’s try that again so you can actually see it:
Point being: if you return a JSON object with a success flag, the agent sees the error as data rather than a crash, and it can actually try to recover. You can also use an IF node after the agent to check intermediateSteps for any ‘success: false’ if you want to trigger a separate alert.
Third time is the charm. My bash shell keeps interpreting the code block. Here is the actual JS pattern:
try {
// Your tool logic here
return { success: true, data: result };
} catch (error) {
// Don't let the node fail, return the error to the agent
return { success: false, error: error.message };
}
(Replace [code] with backticks). If the agent sees { success: false }, it can use its reasoning to retry or fix. This prevents the ‘silent’ workflow stall because the node itself never actually crashes.
Hey @xmateusx14
Ran into this from a different angle my agent skipped its tool entirely rather than the tool failing. Calculator attached, prompt said explicitly not to calculate internally. It did anyway. Correct answer, green node, empty intermediateSteps.
The structured output parser works, but you’re asking the model to report its own failure. I went deterministic instead. Built a Code node you drop after the agent. Eight checks including empty, refusal, bad JSON, missing keys, placeholders, truncation, prompt echo, and required tools that never show up in intermediateSteps. Adds contractOk and contractFailures to each item, so you branch on an IF node. No AI used or dependencies.
Only catches mechanical failures, not wrong answers. If you’ve got real output that breaks a check, send it and that’s how I found a false positive in my own truncation rule.
Ananya ![]()
There’s a third case that sits between the two failure modes in this thread, and it passes every check described so far: the tool ran, returned successfully, and returned nothing.
Mine was a retrieval step feeding an agent. A filter node downstream of it had a stale condition left over from an earlier fix, and it reduced 8 correctly retrieved rows to 0. The tool call itself was fine. It returned success with an empty array.
The try/catch pattern sees success. The structured object sees success. And the contract check sees the tool present in intermediateSteps, so it passes there too. The agent then did the entirely reasonable thing with an empty result set and said it did not have that information. Green everywhere, nothing thrown, and the output was a polite, well formed, completely wrong refusal.
What I would add to the pattern people are describing here: return the count, not just the flag.
try {
const rows = await lookup(q);
return { success: true, count: rows.length, data: rows };
} catch (e) {
return { success: false, error: e.message };
}
Then the IF node checks count === 0 as well as success === false. Zero is a legitimate answer sometimes, so you do not hard fail on it, you log it and watch the rate. A retrieval tool that quietly moves from 5 percent empty to 100 percent empty is broken in a way that looks identical to cautious.
@Ananya_p_kumar on your caveat that it only catches mechanical failures and not wrong answers: I think “empty when it should not be empty” is the one slice of wrong answer that is mechanically detectable, provided the tool reports a count. Might be worth a ninth check, a required tool returned zero rows flag, for tools that declare themselves as returning collections. Happy to send you a sanitized example if useful.
The thing that actually found it for me though, and I would recommend it above any single node: keep a handful of inputs where you already know the correct answer, and run them against production on a schedule. Logs tell you the machine ran. Known answers tell you it was right.
Good catch phantomTool asks whether the tool showed up in intermediateSteps, not whether it came back with anything, so a successful call returning an empty collection looks identical to one returning ten rows. The contract-driven framing is what makes the ninth check workable: declare which tools return collections, flag zero only for those. It needs the tool to report a count, so it depends on your count: rows.length pattern. Opened an issue for it: emptyCollection check for tools that return collections · Issue #1 · Ananyapkumar/agent-contract · GitHub and an example very welcome. And taking your point that empty when it shouldn’t be is the mechanically detectable slice of wrong I’d drawn that line too broadly.
Adam13y’s point is the one worth building on — success/failure is the wrong shape for a tool result. What holds up better is making every tool return {status, count, data}, where status is one of ok / empty / degraded / failed, then asserting on those after the agent rather than inside a tool.
The part that usually gets skipped: for the error workflow to fire at all, that assertion node has to actually throw. An agent that returned a confident wrong answer is a failed execution, and n8n will only treat it as one if something downstream raises.
agreed on the shape, and the throwing part is where I got it wrong first time.I tried making the assertion throw on empty and it was miserable. Empty is often legitimate: nobody asked about something in the corpus, retrieval correctly returns nothing, the agent correctly says it does not know. Throw on that and you page yourself daily and stop reading your own alerts within a week.What worked was splitting it. Is THIS execution wrong: throw, but only at a destructive boundary, where an empty result is about to feed a write or a send or a payment. Is the RATE wrong: do not throw, monitor. A retrieval step sitting at 4 percent empty for months that goes to 100 percent is broken, and no single execution in that window looks different from a legitimate no match. That is what caught my filter bug, and throwing never could have, because every execution was individually defensible.Throwing also marks the run failed, which pollutes your error rate and can trigger retries that re-run side effects. Worth paying only where the action is destructive.
The retry objection is the part I’d push back on, because it’s fixable rather than a permanent tax. Throwing is only expensive when the retry isn’t safe to run twice. Idempotency key on outbound POSTs, upsert on a business key instead of append, claim-the-row-before-send on anything that leaves the building. Do that and re-running a failed execution stops being something you have to weigh before you throw, which means you can afford to be strict at the boundary instead of rationing it.
The other half is that rate monitoring needs volume, and plenty of these workflows don’t have it. At 30 executions a day, a retrieval step drifting from 4 percent empty to 40 takes better than a week to separate from noise, and it’s been wrong that whole time. Your known-answer idea covers exactly that gap: one canary input on a schedule gives you a signal in a single run no matter what the traffic looks like. They’re not competing. Rate catches slow drift where there’s volume to measure, canaries catch it where there isn’t.
One thing about canaries though. Run them through the production workflow with production credentials, not a copy. Your bug lived in a filter node, and a duplicated test workflow would have had a clean filter and passed every time.
Last bit: whatever marks a branch destructive has to be a property of the workflow, not something you remember. A tag on the tool, or just the rule that the assert node sits immediately before every write, send and payment node and nowhere else. Otherwise the strict check ends up on whichever path you were touching the day you built it.
Fair on the retry point, and I think you are right that I was treating it as fixed cost when it is a design choice. Idempotency key on outbound, upsert on a business key, claim the row before send. Do that and throwing stops being something you ration.Your point about running canaries through production rather than a copy is the one I want to underline, because it is stronger than it first looks. A duplicated workflow does not just get a clean filter. It gets fresh credentials, its own rate limit budget, a cold cache, and whatever config the person who cloned it happened to have that day. You end up testing the design instead of the deployed thing, and the deployed thing is the only one serving anyone.One trap worth naming for anyone building this, because it bit me and it is not obvious. A known-answer canary can pass while retrieval is returning nothing at all. If the question you pick has an answer the model already knows, it will answer correctly from its own weights and the canary goes green with an empty context. The canary has to ask something that is only answerable from the corpus. An invented internal fact, a policy line, a number that exists nowhere else. If a general knowledge question can satisfy it, it is not testing retrieval, it is testing the model.On your last paragraph, that the destructive marker has to be a property of the workflow rather than something you remember. Strongly agree, and I would add that it also has to fail loudly when it is absent.I found one of these in my own system this morning. A self learning step gated behind a config flag. The flag was set to true a month ago. The file it reads had never been generated, so the loader returned empty, the filter downstream became a no op, and every run logged one line saying it could not read the file and was ignoring it, no effect. Nothing errored. The flag said on. For a month it did nothing, and the log line was honest enough that I had stopped seeing it.Which is your point exactly. A guard that silently degrades to off is worse than no guard, because you stop looking. If it cannot find what it needs, it should refuse to run rather than continue quietly.
Hey @xmateusx14 yeah unfortunately we have to go with the workaround, what I’m doing is that I’m doing as in the below image:
so basically in the AI agent setting, I’m doing as below:
Now with this option you will get 2 routes: “Success” and “Error”, now you can send the error message to slack or discord …etc or pass it to another sub-workflow that will handle the error and continue the workflow on the success route (Please refer to the first image to see both routes).
Thanks
Using Continue (using error output) on the AI Agent only creates an error branch when the Agent node itself throws. It does not convert a tool error that the agent consumed into a failed execution, so it does not cover the original case.
Keep the tool result deterministic. Return a typed status and a count for collection tools. Immediately before any external side effect, assert the state you require and throw if the contract is broken. Make that side effect idempotent so a retry cannot duplicate it.
Treat empty separately from failed. Empty can be valid, so monitor its rate or test it with a corpus-only canary. A failed tool, malformed result, or missing required tool call should not be left for the model to self-report.

