Skip to main content

JavaScript node

LLMs are great at language and unreliable at arithmetic. Ask one to sum 47 invoice line items and it might round creatively, skip a row, or paraphrase your numbers. And every retry costs you another LLM call.

The JavaScript node fixes that. You write a few lines of code in the agent builder. The workflow runs them on the server, exactly, every time. Sum the items, parse a date, or reshape an API response, then pass the result to the next node. No LLM involved, so it's instant, free, and gives the same answer twice.

Find it in the agent builder: open the Flow canvas, drag a connection into empty space, and pick JavaScript from the node menu.

Writing the code​

Your code is the body of a function. It receives the workflow's data and must return a value:

const items = nodes.fetchOrders.response.body.items || [];
const total = items.reduce((sum, item) => sum + item.price, 0);
return { total, count: items.length, formatted: `$${total.toFixed(2)}` };

Three rules:

  1. Always return something. The returned value is the node's output. Without a return, the node runs and produces nothing.
  2. Return plain data: objects, arrays, strings, numbers, booleans. Anything that fits in JSON. Functions and circular structures fail.
  3. The only thing you can await is docana. It reads your tables (see Reading your tables). There's no fetch and no timers. To call an API, put a webhook node before this one and read its response here.

What your code can read​

The function receives one object with everything the workflow knows, the same data Handlebars templates see:

NameWhat's in it
varsWorkflow variables: output from earlier nodes and state fields
input.textThe message the user sent to start this run
input.filesFiles sent with that message: documentId, filename, mimeType, url (the authenticated download link) and, when the platform stored a text copy of a spreadsheet, convertedFrom. An empty list when there were none
input.messageIdThe id of that message, when it was stored (not in builder test runs)
nodesEarlier node results by node id, e.g. nodes.fetchOrders.response.body
evaluationThe most recent decision node's scores and structured data
contextContext variables for this conversation
envEnvironment variables configured for the agent

So vars.customerName, input.text.toUpperCase(), and nodes.fetchOrders.response.status all work the way you'd expect.

input.files is how a workflow reaches a file the user attached, say a spreadsheet sent on WhatsApp, without guessing. input.files[0].documentId is the document to hand to a webhook, and templates read the same list as {{input.files.0.documentId}} or {{{JSONStringify input.files}}}. It never carries the file's contents, only ids and the link.

Reading your tables​

Say your agent answers tax questions, and the answer hinges on one code from an official table: which cClassTrib applies to NCM 9619.00.00? If the agent finds it through search, it gets a few chunks that look like the question and fills in the rest. Close is wrong here.

If the table is in Docana as one of your Tables, the code can read it directly:

const ncm = input.text.match(/\b\d{4}\.?\d{2}\.?\d{2}\b/)?.[0]?.replace(/\./g, '');
if (!ncm) return { found: false };

const [row, options] = await Promise.all([
docana.tables.lookup('ncm', ncm),
docana.tables.query('cclasstrib_ncm', { ncm_prefix: { in: [ncm.slice(0, 2), ncm.slice(0, 4), ncm] } }),
]);

return {
found: Boolean(row),
descricao: row?.values.descricao ?? null,
codes: options.map((option) => option.values.cclasstrib),
};

Put that in a node with output variable consulta, and the instructions node after it writes the reply from {{consulta.codes}} instead of from memory. When someone updates the table, the agent reads the new rows on its next run. Nothing to republish.

Two methods, both read-only:

CallReturns
docana.tables.lookup('ncm', '96190000')The row with that key, or null
docana.tables.lookup('ncm', ['96190000', '01012100'])An object with one entry per key, null for keys not found. One query, however many keys
docana.tables.query('precos', { uf: 'SP' }, { limit: 10, orderBy: 'preco' })A list of matching rows, [] when none

A row looks like { key: '96190000', values: { descricao: 'Absorventes' } }, with values keyed by column name. In query, { column: value } matches exactly, and { column: { op: value } } takes eq, neq, gt, gte, lt, lte, contains, in (a list of up to 100 values), is_null and is_not_null. Up to 10 conditions, all of which must match.

Three things to know:

  • Declare the tables first. The code reads a table by a name you give it under Resources in the agent's Basic Info tab. Pick the table, name it ncm, and docana.tables.lookup('ncm', ...) reads it. The node's drawer lists the tables its code reads and warns about names you haven't declared. An agent whose code reads an undeclared name can't be published.
  • Don't forget await. Without it you get a Promise instead of rows.
  • It's off by default. Table reads are behind a feature flag for each application. Ask us to turn it on.

Declaring a table lets the published agent read it, whoever is chatting with it: a WhatsApp customer reads through the agent, not with their own permissions. So only declare tables you'd be fine with the agent quoting. Publishing checks that you can read the table yourself, that it belongs to the agent's application, and that it isn't a table where each person sees only their own rows.

If a call fails, it throws an error you can catch, with a name that says why: DocanaResourceNotFound (the name isn't declared), DocanaPermissionDenied (the table was deleted or changed), DocanaQueryError (a bad column or operator), DocanaLimitExceeded or DocanaUnavailable.

Don't paste a copy of the table into the code instead. It works until the table changes, and nobody can tell where the numbers came from.

In an eval, reads hit the real table by default, which is what you want: the eval fails if someone breaks the table. To test a branch the real data never reaches, give the eval spec resourceMocks with the rows to use instead. The run report lists which tables were read for real.

Using the result​

Set the node's Output variable (say, calc) and the returned value becomes available everywhere downstream:

  • In templates: an instructions node can write The total is {{calc.formatted}}
  • In conditions: route the flow with a transition like vars → calc.count → greater than → 0, including on the JavaScript node's own transitions, so you can branch on what the code just computed
  • From other code: another JavaScript node can read nodes.<this-node-id>.result

A common pattern: validate, then branch:

const numbers = input.text.split(',').map((n) => parseFloat(n.trim())).filter((n) => !isNaN(n));
const sum = numbers.reduce((a, b) => a + b, 0);
return { sum, count: numbers.length, valid: numbers.length > 0 };

Give the node two transitions: one where calc.valid equals true goes to the answer, one where it equals false asks the user to try again.

Limits​

The code runs in a sandbox with hard limits, so a typo can't take your agent down:

LimitValue
Time1 second by default, configurable up to 10 seconds
Memory64 MB
Returned value524,288 characters after JSON serialization
Network, files, importsNone: the sandbox has no fetch, no filesystem, no require
Table reads (docana)50 calls per node, 100 rows per call, 2 MB of results per node
Time waiting on table reads30 seconds per node. It doesn't count against the time limit above

console.log works: whatever you log shows up in the run's execution logs.

When things go wrong​

If the code throws, times out, or returns something that doesn't fit in JSON, the node records the error and the workflow keeps going through its transitions. The run doesn't crash. No output variable is written, so guard downstream conditions with an exists check when your code can fail.

Open the agent's Logs tab to see what happened: each JavaScript step shows its returned value, console output, duration, and the error message when it failed.

When to use it (and when not)​

Use a JavaScript node for anything with a single correct answer: math, counting, parsing, date and currency formatting, reshaping an API response, building a string from parts.

Don't use it for:

  • Understanding language: classifying intent or writing a reply is what decision and instruction nodes are for
  • Calling external systems: that's the webhook node. Transform its response here
  • Storing a flag: a state node does that without any code