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:
- Always
returnsomething. The returned value is the node's output. Without areturn, the node runs and produces nothing. - Return plain data: objects, arrays, strings, numbers, booleans. Anything that fits in JSON. Functions and circular structures fail.
- The only thing you can
awaitisdocana. It reads your tables (see Reading your tables). There's nofetchand 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:
| Name | What's in it |
|---|---|
vars | Workflow variables: output from earlier nodes and state fields |
input.text | The message the user sent to start this run |
input.files | Files 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.messageId | The id of that message, when it was stored (not in builder test runs) |
nodes | Earlier node results by node id, e.g. nodes.fetchOrders.response.body |
evaluation | The most recent decision node's scores and structured data |
context | Context variables for this conversation |
env | Environment 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:
| Call | Returns |
|---|---|
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, anddocana.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:
| Limit | Value |
|---|---|
| Time | 1 second by default, configurable up to 10 seconds |
| Memory | 64 MB |
| Returned value | 524,288 characters after JSON serialization |
| Network, files, imports | None: 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 reads | 30 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