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 in a Console block with the tool result in Try it! and in Call history, separate from any Tools called.

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. Returns the file-bound tools for it, plus getBytes() and getDownloadLink() (see below). Errors with a clear message if this tool has no libraries 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, no package manager, and no global fetch (or other raw HTTP client). Call other tools with tools.*. To read a File input, use openFile — do not fetch the file's URL.

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. The same handle can also read the file's bytes or mint a short-lived download link for an attachment.

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, getBytes, getDownloadLink }:

  • 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. A file type with no matching library still opens: tools is empty and you can still read the file.

  • getBytes() returns the current file as bytes (Uint8Array). Use this when your script needs the file itself. Do not return those bytes as the tool result — only JSON-serializable values survive. Files larger than 10 MiB cannot be read this way.

  • getDownloadLink() returns a short-lived signed download URL (expires after 1 hour). Use this when you want to attach the file to an email or pass it to another tool that fetches a file from a URL — including generic_api_post_binary on a REST API connection. Same kind of link as Convert URLs to signed download URLs on a connected tool.

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

Enable at least one library in this tool's Tools section or openFile explains that it is unavailable. Enable the matching library (for example Microsoft Excel) when you need that file's tools. 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.

To upload a File input to a REST API as raw bytes, enable the REST API library and generic_api_post_binary under this tool's Tools section, then pass getDownloadLink(). See Connecting to External Systems and Tools.

const file = await openFile(getTypedInput().attachment);
await tools.generic_api_post_binary({
  path: "/files/upload?name=report.pdf",
  mimeType: file.mimeType,
  publicContentUrl: await file.getDownloadLink(),
});

Messaging a conversation

A custom code tool can also send on a work-item conversation. Add a Conversation input (or a Reference that points at a Conversation property). When the tool runs on a work item, tools includes add_message_to_<property> for that conversation, and getConversationTranscript("EndUserConvo") returns what has been said so far. Set waitMode to waitForReply when the next line of script needs the person's answer. To hand the same conversation to another custom tool, pass getInput().EndUserConvo (or getTypedInput()) — that is the conversation, not the transcript.

Try it! has no work item, so those send tools are not there. How to declare the input, the send-tool arguments, and full examples for both custom code and custom AI tools are in How to build a tool that uses a conversation.

To attach the file to an email, enable Communications and send_email for this tool, then pass the signed URL:

const currentFile = await openFile(getTypedInput().invoice);
const link = await currentFile.getDownloadLink();
await tools.send_email({
  recipientEmails: [getTypedInput().recipient],
  subject: "Invoice",
  body: "See attached.",
  fileAttachments: [{ name: "invoice.pdf", url: link, inlineAsHtml: false }],
});
return { sent: true };

Limits to keep in mind

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

  • Return JSON-serializable values only. getBytes() returns a Uint8Array you can use inside the script; do not return it as the tool result.

  • 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?