Problem: Custom Tool (JavaScript) Not Interpreting JSON Availability Data Correctly — Always Returns ok:false

Hi everyone,

I’m building an n8n workflow where an AI Agent must call a custom JavaScript tool (AvailabilitySlotsParserTool).

This tool receives as input a stringified JSON with real availability data coming from SevenRooms API.

The problem:

:backhand_index_pointing_right: Even though the availability JSON clearly contains times, the tool always returns:

{
“ok”: false,
“message”: “:cross_mark: No hay disponibilidad…”,
“slots”:
}

So the AI always believes there is no availability, even when the data obviously contains multiple time slots like “19:00”, “19:30”, etc.

:wrench: Setup

1. The Tool is configured as a JavaScript Tool (ToolsAgent V2)

  • The tool receives one parameter: query

  • query is always a string containing the JSON object

  • Specify Input Schema is turned off (so the agent sends only { query: “…” })

2. Input that the tool receives

This is the real value of $json.query when the tool runs (stringified JSON):

“{“availability_http”:{“status”:200,“data”:{“availability”:[{“name”:“Dinner”,“shift_persistent_id”:“ahN…”,“times”:[{“sort_order”:52,“time”:“19:00”, … }, {“time”:“19:30”}, {“time”:“20:00”} ]}]},“params”:{“date”:“2025-11-28”,“party_size”:2}}”

We can clearly confirm that:

  • “time”: “19:00”

  • “time”: “19:30”

  • “time”: “20:00”

are present in the JSON.

// AvailabilitySlotsParserTool – versión simple para STRING query
// --------------------------------------------------------------
const raw = $json.query;

// 1) Parsear string a objeto
let q;
try {
q = JSON.parse(raw);
} catch (e) {
return JSON.stringify({
ok: false,
message: “ERROR en AvailabilitySlotsParserTool: query no es JSON válido”,
meta: {},
slots:
});
}

// 2) Extraer availability
const avRoot = q.availability_http || {};
const data = avRoot.data || avRoot.body?.data || {};
let availability = data.availability || ;

if (!Array.isArray(availability) && availability && typeof availability === “object”) {
availability = [availability];
}

// 3) Recoger horarios
const slots = ;
const seen = new Set();

for (const shift of availability) {
if (!shift) continue;

let times = shift.times || ;
if (!Array.isArray(times) && times && typeof times === “object”) {
times = [times];
}

for (const t of times) {
if (!t || !t.time) continue;
const timeStr = String(t.time);
if (!seen.has(timeStr)) {
seen.add(timeStr);
slots.push({ time: timeStr });
}
}
}

// 4) Params / meta
const params = q.params || {};
const dateText = params.date || null;
const paxText = params.party_size || null;

// 5) Mensaje
let message;

if (slots.length > 0) {
slots.sort((a, b) => a.time.localeCompare(b.time));
const lines = slots
.slice(0, 8)
.map((s, i) => ${i + 1}) ${s.time});

message =
✅ Disponibilidad encontrada +
(dateText ? para ${dateText} : “”) +
(paxText ? (${paxText} pax) : “”) +
:\n +
lines.join(“\n”) +
\n\nResponde con el número de opción para continuar.;
} else {
message =
❌ No hay disponibilidad +
(dateText ? para ${dateText} : “”) +
(paxText ? (${paxText} pax) : “”) +
. ¿Quieres que te proponga otros horarios o días cercanos?;
}

// 6) Salida
const result = {
ok: slots.length > 0,
message,
meta: {
venue_id: null,
date: dateText,
party_size: paxText
},
slots
};

return JSON.stringify(result);

:cross_mark: Expected vs Actual Behavior

Expected:

The tool should return:

{
“ok”: true,
“slots”: [
{ “time”: “19:00” },
{ “time”: “19:30” },
{ “time”: “20:00” }
],
“message”: “Disponibilidad encontrada…”
}

Actual:

Regardless of input, the tool returns:

{
“ok”: false,
“message”: “:cross_mark: No hay disponibilidad…”,
“slots”:
}

Even though the stringified JSON definitely contains “time”: “19:00” (and others).

:face_with_monocle: What I Suspect

One of these must be happening:

  1. The agent is double-escaping the JSON (e.g., JSON → string → string → tool), so parsing becomes inconsistent.

  2. ToolsAgent V2 might be wrapping the input inside internal envelope fields in a way that changes $json.query.

  3. The tool may be receiving escaped JSON inside a nested query, e.g.:

{
“query”: “{ “query”: “{ … }” }”
}

  1. Or n8n is passing an object instead of a pure string, causing the string search to fail.

:red_question_mark: My Question

:backhand_index_pointing_right: How do I ensure that a ToolsAgent custom JS tool receives a CLEAN string as $json.query, without extra escaping or wrapping?

:backhand_index_pointing_right: Is there a recommended way to debug the exact raw payload that ToolsAgent sends into the tool?

:backhand_index_pointing_right: Is there any known issue when tools receive long stringified JSON bodies from previous nodes?

Any insights would be hugely appreciated — been fighting this for days.

Thanks in advance!

Hi @ElCoRdobEs :n8n: and welcome!

Could you please attach a sample workflow with pinned data that demonstrates the case?

It’s difficult to reproduce the issue based on text alone.

Also, please make sure you’re using the latest version of AI Agent (3)..

And unfortunately here discourse is mangling the code and json snippets you’re pasting in.

I’d recommend switching to the Standard Markdown Editor mode when replying.

and using the markdown tripple back ticks ``` to enclose the code snippets.

Hi! Thanks for the quick reply.

As requested, here is a minimal reproducible workflow with:

  • One node containing pinned data simulating the SevenRooms availability response
  • My AvailabilitySlotsParserTool (custom JS tool used by the AI Agent)
  • A debug node that prints the tool’s output

:paperclip: Attached workflow:

availability-parser-minimal.json

This file is extremely simple (only 3 nodes) and reproduces the issue 100% of the time.

What happens:

Even though the pinned input contains:

“times”: [
{ “time”: “19:00” },
{ “time”: “19:30” },
{ “time”: “20:00” }
]

…the parser ALWAYS outputs:

{
“ok”: false,
“slots”: ,
“message”: “:cross_mark: No availability…”
}

Important notes:

  • I’m using AI Agent v3
  • The parser is called by the agent with a query parameter
  • $json.query contains the full JSON string (correctly escaped)
  • But inside the tool, $json.query is not parsed or interpreted correctly(it seems wrapped, escaped, or nested unexpectedly)
  • No matter what structure I try to parse, the tool does not detect “time”: “HH:MM” inside the JSON

What I need help with:

:backhand_index_pointing_right: How can I guarantee that a ToolsAgent JS tool receives the RAW string passed by the AI as $json.query

(without being re-wrapped or re-escaped internally)?

:backhand_index_pointing_right: Is there a recommended pattern for tools that receive long JSON strings from AI Agent v3?


Let me know if you need a version where the tool is called directly by the agent instead of manually.

<{
“name”: “availability-parser-minimal”,
“nodes”: [
{
“parameters”: {
“functionCode”: “return [\n {\n json: {\n query: "{\"availability_http\":{\"status\":200,\"data\":{\"availability\":[{\"name\":\"Dinner\",\"times\":[{\"time\":\"19:00\"},{\"time\":\"19:30\"},{\"time\":\"20:00\"}]}]},\"params\":{\"date\":\"2025-11-28\",\"party_size\":2}}"\n }\n }\n];”
},
“id”: “63147897-725a-4cfd-840c-092af9cbbf16”,
“name”: “Pinned Availability Data”,
“type”: “n8n-nodes-base.function”,
“typeVersion”: 1,
“position”: [
368,
48
]
},
{
“parameters”: {
“jsCode”: “// AvailabilitySlotsParserTool – versión DEFINITIVA\n// ------------------------------------------------\n// Funciona cuando $json.query es OBJETO o STRING.\n// Extrae horarios de availability_http.data.availability[].times[].time\n// y, si falla, hace fallback por regex sobre todo el contenido.\n//\n// Siempre devuelve UN STRING con JSON:\n// {\n// ok: true/false,\n// message: "…",\n// meta: { date, party_size, … },\n// slots: [ { time: "19:00" }, … ]\n// }\n\n// 1) Leer query crudo\nlet raw = $json.query;\n\n// 2) Normalizar query a OBJETO q y a TEXTO text\nlet q;\nlet text;\n\ntry {\n if (typeof raw === "string") {\n // raw es string JSON\n text = raw;\n q = JSON.parse(raw);\n } else {\n // raw ya es objeto\n q = raw || {};\n text = JSON.stringify(raw || {});\n }\n} catch (e) {\n // Si falla el parseo, usamos fallback solo con regex\n q = {};\n text = typeof raw === "string" ? raw : "";\n}\n\n// 3) Extraer params si existen (no obligatorio)\nlet params = {};\nif (q && typeof q === "object" && !Array.isArray(q) && q.params && typeof q.params === "object") {\n params = q.params;\n}\n\nconst dateFromParams = params.date || null;\nconst paxFromParams = params.party_size || null;\n\n// 4) Primer intento: leer availability_http.data.availability[].times[].time\nconst slots = ;\nconst seen = new Set();\n\ntry {\n const avRoot = q.availability_http || {};\n const data = avRoot.data || avRoot.body?.data || {};\n let availability = data.availability || ;\n\n if (!Array.isArray(availability) && availability && typeof availability === "object") {\n availability = [availability];\n }\n\n for (const shift of availability) {\n if (!shift) continue;\n\n let times = shift.times || ;\n if (!Array.isArray(times) && times && typeof times === "object") {\n times = [times];\n }\n\n for (const t of times) {\n if (!t || !t.time) continue;\n const timeStr = t.time;\n if (!seen.has(timeStr)) {\n seen.add(timeStr);\n slots.push({ time: timeStr });\n }\n }\n }\n} catch (e) {\n // si algo falla, pasamos al fallback\n}\n\n// 5) Fallback: si slots sigue vacío, buscar "time":"HH:MM" en TODO el texto\nif (slots.length === 0 && text) {\n const timeRegex = /"time"\s*:\s*"(\d{1,2}:\d{2})"/g;\n let m;\n while ((m = timeRegex.exec(text)) !== null) {\n const t = m[1];\n if (!seen.has(t)) {\n seen.add(t);\n slots.push({ time: t });\n }\n }\n}\n\n// 6) Detectar fecha: primero params.date, si no real_datetime_of_slot\nlet detectedDate = dateFromParams;\nif (!detectedDate) {\n try {\n const avRoot = q.availability_http || {};\n const data = avRoot.data || avRoot.body?.data || {};\n const first = Array.isArray(data.availability) ? data.availability[0] : null;\n const firstTime = first?.times?.[0]?.real_datetime_of_slot;\n if (firstTime && typeof firstTime === "string") {\n detectedDate = firstTime.split(" ")[0];\n }\n } catch (e) {}\n}\n\n// 7) Construir mensaje para WhatsApp\nlet message = "";\nconst dateText = detectedDate || "";\nconst paxText = paxFromParams || "";\n\nif (slots.length > 0) {\n slots.sort((a, b) => a.time.localeCompare(b.time));\n const lines = slots.map((s, i) => ${i + 1}) ${s.time});\n\n message =\n ✅ Disponibilidad encontrada +\n (dateText ? para ${dateText} : "") +\n (paxText ? (${paxText} pax) : "") +\n :\\n +\n lines.join("\n") +\n \\n\\nResponde con el número de opción para continuar.;\n} else {\n message =\n ❌ No hay disponibilidad +\n (dateText ? para ${dateText} : "") +\n (paxText ? (${paxText} pax) : "") +\n . ¿Quieres que te proponga otros horarios o días cercanos?;\n}\n\n// 8) Devolver resultado como STRING JSON\nconst result = {\n ok: slots.length > 0,\n message,\n meta: {\n venue_id: q.availability_http?.data?.venue_id || null,\n date: dateText || null,\n start_time: params.start_time || null,\n end_time: params.end_time || null,\n party_size: paxFromParams || null,\n duration: params.duration || null\n },\n slots\n};\n\nreturn JSON.stringify(result);”
},
“type”: “n8n-nodes-base.code”,
“typeVersion”: 2,
“position”: [
576,
48
],
“id”: “f43bce1c-335f-43fd-bc9f-5331aad6a216”,
“name”: “Parsel”
}
],
“pinData”: {},
“connections”: {
“Pinned Availability Data”: {
“main”: [
[
{
“node”: “Parsel”,
“type”: “main”,
“index”: 0
}
]
]
}
},
“active”: false,
“settings”: {
“executionOrder”: “v1”
},
“versionId”: “be9bb4c0-be4b-40b3-8c01-75c919d5d5f8”,
“meta”: {
“instanceId”: “a467d1c78e48dfceb653546d1df899567207506ad61e666365373c94e16691cc”
},
“id”: “SMEdZhqAqSDCzuY8”,
“tags”:
}

Someone can help me with this?

Thanks a lot!