Skip to main content

How custom code tools work

The JavaScript format, available globals, and how a custom code tool reads inputs, calls other tools, and returns a result.

How custom code tools work

A custom code tool is a JavaScript function you add to a thunk so the AI agent can run deterministic logic during a workflow — calculations, status checks, lookups, and calls to other tools. The agent calls the tool by name, passes structured inputs, and uses whatever the function returns.

Use a code tool when the same inputs should always produce the same output: comparisons, date arithmetic, filtering a list, or chaining a few integration calls in a fixed order. If the work needs language understanding or judgment, use a custom AI tool instead.

This article is the JavaScript contract: how to write the function, which globals exist, and how to return a result. Naming, input descriptions, and the shared tool-builder sections are covered in Custom Tools.

Create the tool

  1. Open Custom-built tools in the left navigation.

  2. Click Add New Tool and choose Custom Code Tool.

  3. Fill in the tool name and description (the AI agent uses both to decide when to call it).

  4. Define Inputs — each field the caller must pass, with a type and description.

  5. Optionally define Outputs — named fields for an object result, or a single value if the tool returns a scalar such as "YES".

  6. Write the JavaScript in the Code editor, or let the tool builder write it from the tool's intent.

Under Tools, enable any libraries this script may call (for example Microsoft Excel or another application library). Those tools appear on the tools object inside the script. More libraries can be turned on at the thunk level.

Try it! runs the tool with sample arguments before you rely on it in a workflow. Add lasting cases on the Tests tab — see Automated tests for custom tools.

JavaScript format

The code is plain JavaScript. You cannot import or require packages. Standard language features work, including async/await, optional chaining (?.), destructuring, and template literals.

There are two valid shapes.

Function body (preferred)

Write statements directly. Thunk.AI runs them inside an async function, so top-level await and return are allowed. Do not add an extra async function run() { ... }; run(); wrapper, and do not end with (async () => { ... })(); — those extra wrappers discard the return value, so the tool appears to return nothing.

const input = getTypedInput();
const status = (input.CurrentStatus || "").trim();
if (["Completed", "Cancelled", "Closed"].includes(status)) {
  return "NO";
}
return "YES";

Full async function

The entire editor contents can be one async function. Thunk.AI calls that function and passes the raw input object.

async function run(input) {
  const status = (input.CurrentStatus || "").trim();
  if (["Completed", "Cancelled", "Closed"].includes(status)) {
    return "NO";
  }
  return "YES";
}

The script is treated as a full function only when it starts with async (after leading whitespace). A comment or helper before async function changes how the code is run. Prefer the function-body form unless you intentionally want a single callable async function.

Inside a full function, the input argument is the raw object from getInput(). You can still call getTypedInput() from the body if you want schema-typed fields.

Reading inputs

Helper

What it returns

getTypedInput()

The same arguments, with each field coerced to its declared input type ("5" becomes 5, "true" / "yes" become true). Missing optional fields pick up a schema default when one is set. Prefer this.

getInput()

The raw arguments object after schema validation, with no extra coercion.

Field names match the Inputs section and are case-sensitive. Guard against empty strings and unused optional fields:

const input = getTypedInput();
const name = (input.CustomerName || "").trim();
if (!name) {
  throw new Error("CustomerName is required");
}

If the tool needs "today", declare it as an input (for example CurrentDate) instead of reading the clock. A tool that reads the current time cannot be tested from a fixed test case.

What to return

return a JSON value: an object, array, string, number, boolean, or null. Do not return functions or other non-JSON values.

  • A string or number is a scalar result (for example "YES" or a count). That matches an Outputs contract that declares a single value.

  • An object with named fields matches an Outputs property list. The AI agent reads those fields by name in later steps.

Always return something. If the function ends without a return, callers see an empty result.

Globals always available

These exist in every custom code tool:

Global

Purpose

getTypedInput() / getInput()

Read the tool's arguments (see above).

console.log / console.warn / console.error

Write trace lines. They show up with the tool result in Try it! and in Call history.

sleep(ms)

Pause for the given milliseconds (await sleep(1000)). Use this between status polls or rate-limited calls. Negative or non-numeric values are treated as zero. Total run time is still limited to about 15 minutes.

thunk.dates

UTC date helpers. Use these instead of new Date(str) plus getDay() / setDate() — those mix UTC parsing with local time and can disagree depending on timezone.

openFile(url)

Open a File input and get the file-bound tools for it (see below). Errors with a clear message if this tool has no file library enabled.

Ordinary JavaScript built-ins are available too (JSON, Math, Array, Object, String, Number, Boolean, Promise, Map, Set, Date). There is no Node.js process, no file system, and no package manager.

Dates: thunk.dates

Every code tool has thunk.dates. It is all UTC. Pass anything date-like ("YYYY-MM-DD", a longer ISO string, a Calendar Date field, or a Date) and get a Date back — or null if the value cannot be parsed (impossible dates such as 2026-02-31 are rejected rather than rolled over).

Helper

Result

thunk.dates.parse(value)

Date at UTC midnight, or null

thunk.dates.format(value)

"YYYY-MM-DD", or null

thunk.dates.addCalendarDays(value, n)

Shift by n calendar days (n may be negative)

thunk.dates.addBusinessDays(value, n)

Shift by n weekdays (skips Saturday and Sunday; no holiday calendar)

thunk.dates.calendarDaysBetween(from, to)

Signed whole days; 0 for the same day

thunk.dates.businessDaysBetween(from, to)

Signed weekdays; 0 for the same day

thunk.dates.isWeekend(value) / isBusinessDay(value)

Boolean, or null if unparseable

thunk.dates.compare(a, b)

-1, 0, or 1

thunk.dates.max(a, b) / min(a, b)

Null-tolerant

thunk.dates has no "today" helper. Pass the current date in as an input.

const input = getTypedInput();
const start = thunk.dates.parse(input.StartDate);
const today = thunk.dates.parse(input.CurrentDate);
if (!start || !today) {
  throw new Error("StartDate and CurrentDate must be YYYY-MM-DD");
}
return {
  continueTracking: false,
  DaysOpen: thunk.dates.calendarDaysBetween(start, today),
};

Calling other tools: tools

When you enable libraries under the tool's Tools section, they appear as async methods on tools. For example, after you enable an integration that can list and update customers:

const customers = await tools.list_customers({});
const match = customers.find((c) =>
  (c.name || "").toLowerCase().includes(input.nameSubstring.toLowerCase()),
);
if (!match) {
  throw new Error("No customer matched that name");
}
await tools.update_customer({ id: match.id, company: input.newCompany });
return { updatedCustomerId: match.id, updatedCompany: input.newCompany };

Names are turned into valid JavaScript identifiers: lowercased, and characters other than letters, digits, _, and $ become underscores. So a tool advertised as list-customers is called as await tools.list_customers({ ... }) (or tools["list_customers"]). Do not write tools.list-customers — that is invalid JavaScript.

Pass only the business arguments the tool's schema lists. Do not copy extra reasoning fields you might see when the AI agent calls tools in a chat.

If Tools has nothing enabled, tools is not defined. Enable the library first, or the script cannot call it.

Working with files: openFile

File tools (Excel, Google Sheets, Google Docs, Word) bind to an open file rather than standing alone. When a caller passes a File input, open it and call those tools on the handle — not on tools.

const currentFile = await openFile(getTypedInput().workbook);

const rows = await currentFile.tools.lookup_excel({
  where: [{ columnName: "sku", filter: "A-1" }],
  select: null,
  firstMatchOnly: true,
});

await currentFile.tools.append_to_excel_workbook({
  rows: [{ update: [{ name: "sku", value: "B-2" }] }],
});

return { url: currentFile.url, rowCount: rows.length };

openFile(url, options?) returns { url, mimeType, tools }:

  • url is where the file actually resolved (it can differ from the string you passed).

  • tools holds only the tools bound to that file, with the same name rules as tools.*. Inspect Object.keys(currentFile.tools) rather than assuming names — for example the Excel append tool is append_to_excel_workbook.

  • options.refresh (openFile(url, { refresh: true })) re-binds after your code changes the file's shape, such as adding a column.

Enable the matching library for this tool (for example Microsoft Excel) or openFile explains that nothing can bind. A File input whose access is Read only gets the read tools only — in-place updates are withheld.

tools.* stays the file-independent set. File tools live on the handle from openFile, not on tools.

Limits to keep in mind

  • No import / require, no npm packages, no access to the machine's file system.

  • Return JSON-serializable values only.

  • The run is time-bounded (about 15 minutes), including any sleep calls.

  • Empty code fails with an error that the tool has not been implemented yet.

  • console lines are captured for debugging; they are not the tool's return value — always return the result.

Once the script behaves in Try it!, capture that invocation as a test case so later edits cannot silently change the result.

Did this answer your question?