Programmatic Tool Calling in the Oracle Database with MLE JavaScript

Letting an LLM write one small program that calls your tools in a loop beats round-tripping every call through the model. Here is how I built that for UC AI with Oracle MLE, and why the sandbox was the hard part.

Tool calling can be inefficient

The tool calling loop is simple and it works: the model asks to execute a function, you run it, the result goes back into the conversation, the model asks for the next one. I wrote about designing good tools a while ago, and that loop is still the backbone of every agent.

But look at what happens with a question like “which engineers went over their travel budget last quarter?”:

  1. Get the team members. One call, small result.
  2. Get the expenses for each of the eight members. Eight calls, and each returns 15 line items with merchant, receipt URL, cost center, project code, approval chain, notes.
  3. Sum up the approved travel rows per person.
  4. For everyone over the standard limit, look up whether they have a custom budget.

Two things hurt here. First, every tool call turn is a full round-trip to the model. Second, and worse, all that raw data ends up in the context window and it stays there for the rest of the conversation. The model needs three numbers out of a few hundred expense rows, but it pays for all of them, on every following turn.

Also this is mostly a logical problem that requires a loop and summation, no intelligence required.

Programmatic tool calling

Anthropic’s term for the fix is Programmatic Tool Calling (PTC), and there is a cookbook entry with the expense example above. The idea: instead of calling tools one at a time, the model writes a small program that calls the tools, loops, filters, aggregates, and returns only the final answer.

The intermediate data never reaches the model. In the cookbook’s run the difference is 110k tokens versus 16k for the same question, an 85% reduction. That is quite a significant cost reduction.

Of course the specific use-case matters the most:

  • Good fit: many calls, loops over lists, big metadata-heavy payloads you only need an aggregate from, or follow-up calls that depend on earlier results.
  • Bad fit: one or two tool calls, small results, or anything where you want a human (or the model) to look at each individual step. A program that runs 20 tools unsupervised is exactly what you do not want for “send email” or “delete record”.

UC AI – is PTC possible in the Oracle DB?

UC AI is the open-source PL/SQL framework for using LLMs from inside the Oracle database that I am building. It is provider agnostic (Anthropic, OpenAI, Google, xAI, Mistral, OpenRouter, OCI, Ollama), with tools, reasoning, structured output, prompt profiles and multi-agent workflows.

Tools in UC AI are rows in a table: a code, a description, a JSON schema for the parameters, and a snippet of PL/SQL to execute. So when I read the PTC cookbook, the question was obvious: can the model write a program that calls those tools?

Two problems stood in the way.

Problem 1: the language. The obvious choice for a program that runs in the database is PL/SQL. But models are far weaker at PL/SQL than at Python or JavaScript; there is simply orders of magnitude less of it in the training data.

Problem 2: the sandbox. Model-authored code is untrusted input. It is one prompt injection away from being attacker-authored code. Running it with the privileges of the schema that owns data is not an option.

MLE JavaScript ticks both boxes

Oracle 23ai ships the Multilingual Engine (MLE), which runs JavaScript (and other languages) inside the database. That solves problem 1 immediately: JavaScript is a language every model writes fluently, and it needs no external runtime, no container, no network hop. DBMS_MLE lets you evaluate a snippet from PL/SQL and pass values in and out:

declare
  l_ctx sys.dbms_mle.context_handle_t;
  l_out clob;
begin
  l_ctx := sys.dbms_mle.create_context();
  sys.dbms_mle.eval(l_ctx, 'JAVASCRIPT', q'~
    const b = require("mle-js-bindings");
    b.exportValue("result", "hello from JavaScript");
  ~');
  sys.dbms_mle.import_from_mle(l_ctx, 'result', l_out);
  sys.dbms_mle.drop_context(l_ctx);
  dbms_output.put_line(l_out); -- hello from JavaScript
end;
/

So the feature works like this: if enabled, UC AI adds one extra tool to the ones the model already sees, uc_ai__run_code. Its description contains a catalog of the tools that are callable from inside a program:

Callable tools (await callTool code -> arguments):
  await callTool("GET_TEAM_MEMBERS", { department })  // List all members of a department.
  await callTool("GET_EXPENSES", { employee_id, quarter })  // All expense line items of one employee for a quarter.
  await callTool("GET_CUSTOM_BUDGET", { employee_id })  // Custom travel budget of one employee.

And the model answers the whole question with a single tool call containing a program like this:

const team = await callTool("GET_TEAM_MEMBERS", { department: "engineering" });
const over = [];

for (const member of team) {
  const expenses = await callTool("GET_EXPENSES", {
    employee_id: member.id,
    quarter: "Q3",
  });

  const travel = expenses
    .filter((e) => e.category === "travel" && e.status === "approved")
    .reduce((sum, e) => sum + e.amount, 0);

  let budget = 5000;
  if (travel > budget) {
    const custom = await callTool("GET_CUSTOM_BUDGET", {
      employee_id: member.id,
    });
    if (custom.travel_budget) budget = custom.travel_budget;
  }

  if (travel > budget) over.push({ id: member.id, travel, budget });
}

const result = { over_budget: over };

Only result goes back to the model. All those expense rows (merchant names, receipt URLs, approval chains) stay in the database.

Enabling it is one flag on top of normal tool calling:

begin
  uc_ai.g_enable_tools              := true;
  uc_ai.g_enable_programmatic_tools := true;
  uc_ai.g_tool_tags                 := apex_t_varchar2('reporting');

  declare
    l_result json_object_t;
  begin
    l_result := uc_ai.generate_text(
      p_user_prompt => 'Which engineers exceeded their Q3 travel budget?'
    , p_provider    => uc_ai.c_provider_anthropic
    , p_model       => uc_ai_anthropic.c_model_claude_4_6_sonnet
    );
    dbms_output.put_line(l_result.get_clob('final_message'));
  end;
end;
/

The model still has the normal tools too. Code mode is an additional option it can pick when a task benefits from it.

The sandbox is the interesting part

Now to problem 2. My first instinct was the classic Oracle answer: create a dedicated, low-privilege, NO AUTHENTICATION schema, put the runner package there as a definer’s rights unit, and only grant execute on a thin gateway package that runs the tools.

But MLE can go a step further and take the ability to run SQL away completely. You can create a PURE MLE environment (docs), which is a restricted execution context. In a PURE context all the database-facing JavaScript APIs are simply gone:

create or replace mle env uc_ai_ptc_pure_env pure;
--
l_ctx := sys.dbms_mle.create_context(environment => 'UC_AI_PTC_PURE_ENV');

Inside such a context, require("mle-js-oracledb") raises ORA-04103, and the oracledb, session, soda and plsffi globals do not exist. No SQL means no reads, no writes, and no transaction control.

If the program cannot run SQL, how does it call tools?

That is the trade-off PURE forces, and it is a nice one to make. The program cannot execute anything itself, so callTool has to be serviced from the PL/SQL side:

  1. The program awaits callTool("GET_EXPENSES", {...}). Under the hood that queues the request, exports it, and returns a promise.
  2. The eval() call itself returns, leaving the program suspended mid-await. The runner picks up the request with the framework’s privileges.
  3. The runner exports the result and settles the promise. The program continues until the next await or until it is done.

Step 2 is the part that feels like magic, because await does not normally hand control back to the host language. DBMS_MLE.eval() returns as soon as the JavaScript has nothing left to run, and inside a PURE context the program has no way to settle that promise by itself: no timers, no I/O, nothing that could fire later. So the program freezes mid-await and PL/SQL carries on. The only thing that can wake it up is the host, and the context survives the call — so the next eval() runs __resume(), which settles the promise and lets the program continue to its next await.

The JavaScript half is injected before the model’s code and does no work of its own — it only parks the request and hands back a promise:

function callTool(name, args) {
  return new Promise(function (resolve, reject) {
    queue.push({ res: resolve, rej: reject });
    bindings.exportValue("uc_ai_req_tool", name); // what PL/SQL should run
    bindings.exportValue("uc_ai_req_args", JSON.stringify(args));
    bindings.exportValue("uc_ai_state", "call"); // "I am waiting"
  });
}

globalThis.__resume = function () {
  const pending = queue.shift();
  bindings.exportValue("uc_ai_state", "run");
  // settling the promise lets the program run on to its next await
  pending.res(JSON.parse(bindings.importValue("uc_ai_res")));
};

And the PL/SQL half is the loop that keeps feeding it (error handling and the call budget stripped for clarity):

sys.dbms_mle.export_to_mle(l_ctx, 'uc_ai_code', p_code);
sys.dbms_mle.eval(l_ctx, 'JAVASCRIPT', gc_bootstrap); -- starts the program

<<trampoline>>
loop
  sys.dbms_mle.import_from_mle(l_ctx, 'uc_ai_state', l_state);
  exit trampoline when l_state != 'call'; -- 'done' or 'error'

  sys.dbms_mle.import_from_mle(l_ctx, 'uc_ai_req_tool', l_tool);
  sys.dbms_mle.import_from_mle(l_ctx, 'uc_ai_req_args', l_args);

  -- the gateway checks the allow-list, the budget and the hook, then runs the tool
  l_res := uc_ai_ptc_api.call_tool_json(l_tool, l_args);

  sys.dbms_mle.export_to_mle(l_ctx, 'uc_ai_res', l_res);
  sys.dbms_mle.eval(l_ctx, 'JAVASCRIPT', '__resume();');
end loop trampoline;

sys.dbms_mle.import_from_mle(l_ctx, 'uc_ai_result', l_result);

A small trampoline, and it has a pleasant side effect: because the tool runs in a plain PL/SQL call from the runner, it executes in the caller’s transaction, exactly like a direct tool call. Tools keep their normal semantics; only the JavaScript is caged.

Do the models actually use it?

Numbers are more fun than prose, so I built the cookbook’s expense scenario as UC AI tools: eight engineers, 15 metadata-rich expense rows each (~2,600 tokens per employee), a standard 5,000 USD travel budget and two custom budgets.

Then I ran the exact same prompt per provider: once with classic tool calling, once with code mode available. I wanted to see whether the model reaches for it on its own. And because LLMs are not deterministic, I ran the whole thing twice and report both numbers.

Run 1 and run 2 are the same identical experiment twice, so any spread between them is the model being non-deterministic.

Provider / modelModeCalls run 1Calls run 2Tokens run 1Tokens run 2
Anthropic claude-sonnet-4-6classic171731.7k31.6k
Anthropic claude-sonnet-4-6code114.3k4.6k
OpenAI gpt-5-miniclassic171724.5k24.6k
OpenAI gpt-5-minicode112.0k2.2k
Google gemini-3-flashclassic171734.0k33.9k
Google gemini-3-flashcode113.1k2.8k
xAI grok-4-fastclassic171723.4k23.2k
xAI grok-4-fastcode221.5k23.7k
Mistral mistral-mediumclassic171732.8k32.8k
Mistral mistral-mediumcode7125.3k3.0k

All twenty runs landed on the correct set of employees.

A few observations:

Every provider reached for the program on its own. No nudging, no “prefer the code tool” in the system prompt. Given a tool that can loop, all five used it. That surprised me; I expected at least one to ignore it.

The token savings are real when the model commits to one program. In eight of the ten code-mode runs 17 tool calls become one and the conversation shrinks by 85–94%: 31.7k → 4.3k (claude-sonnet-4-6), 24.5k → 2.0k (gpt-5-mini), 34.0k → 3.1k (gemini-3-flash), 23.4k → 1.5k (grok-4-fast), 32.8k → 3.0k (mistral-medium). Right in the range the Anthropic cookbook reports. And note where the saving comes from: not from classic mode being stupid, but from those expense rows being re-read on every one of the 17 turns, versus never reaching the model at all.

There can be inconsistencies. Mistral’s first run took 7 calls and 25.3k tokens; Grok’s second took 2 calls and 23.7k, both roughly what classic mode costs. As LLMs are non-deterministic, that can happen.

Both modes got the answer right once the question was unambiguous. My first version of the prompt said to check custom budgets “for anyone above the standard budget”, which quietly excluded the employee who was under 5,000 but had a 2,000 custom limit. Every model followed my wording faithfully and my ground truth was wrong. A good reminder that most “the model got it wrong” moments are prompt bugs.

Document what your tools return An earlier version of this benchmark did not, and Claude paid for it. Its first program did this:

callTool("BENCH_GET_EXPENSES", { employee_id: member.employee_id, ... })

The field is called id, not employee_id. So member.employee_id was undefined, my (deliberately naive) tool happily returned rows for employee null, and the program produced {"exceeded":[],"summary":[]}. Claude noticed the empty result, wrote a second program just to inspect the data shapes, found the real field name, and wrote a correct third one.

The fix is free:

p_description => 'List all members of a department. '
              || 'Returns [{ id, name, department, role }].'

The table above is what that one sentence per tool buys. Without it, code mode was worse than classic mode for Claude: four calls and 83k tokens, most of that spent discovering the shape of the data. With it: one call and 4.3k, twice in a row. I now think it is the single most valuable thing you can do to make code mode reliable.

Applying it in a racing series agent

Our customer Zenith Racing Series runs a car racing series, and we build their timing and admin platform on Oracle 26ai. One of the next big features is an AI chat (the APEX UC AI chat plug-in) that answers questions about the regulations, out of a RAG collection, and about what actually happened on track.

That second half is what made me want PTC. The timing schema holds 40,013 laps across 39 sessions; the biggest single race is 5,480 clean laps from 48 cars, next to a 35.5 million row GPS table. One session of lap rows alone is 1.2 MB of JSON. Way too much for the context window.

Pre PTC the agent already had three race tools, and every number they returned was aggregated in PL/SQL first. All correct, and necessary, because each result had to fit in the context window, but it also meant the agent could only answer questions I had anticipated.

With PTC the calculation inverts. Those three tools became nine, six of them marked code-only and returning plain rows: laps with their sector splits, stints, pit stops, flag periods, race control messages.

Questions like “Which car was quickest in the final hour?”, “How many cars set a clean lap under 2:20?” or “Compare these two cars lap by lap.” were out of reach before — not because the data was missing, but because the aggregation had to be decided when I wrote the tool. Here is what the model wrote to answer the first two with PTC in one tool call (lightly trimmed):

// which race are we talking about? -> resolves "the last race" to an id
const ctx = await callTool("ZRS_RESOLVE_CONTEXT", {});

// one bulk call: every lap of the session, but only the four fields needed
const allLaps = await callTool("ZRS_GET_LAPS", {
  session_id: ctx.session.session_id,
  fields: ["car_number", "hour_bucket", "lap_time_ms", "is_clean"],
  max_rows: 10000,
});

// "final hour" = the highest hour bucket that actually has laps
const maxHour = Math.max(...allLaps.rows.map((r) => r.hour_bucket || 0));

// collect the lap times of that hour per car
const carTimes = {};
for (const lap of allLaps.rows) {
  if (lap.hour_bucket !== maxHour || lap.lap_time_ms == null) continue;
  (carTimes[lap.car_number] ??= []).push(lap.lap_time_ms);
}

// quickest car = lowest median lap time (median ignores traffic and pit laps)
let bestCar = null;
let bestMedian = Infinity;
for (const [car, times] of Object.entries(carTimes)) {
  times.sort((a, b) => a - b);
  const mid = Math.floor(times.length / 2);
  const median =
    times.length % 2 ? times[mid] : (times[mid - 1] + times[mid]) / 2;
  if (median < bestMedian) {
    bestMedian = median;
    bestCar = car;
  }
}

// second question: clean laps quicker than 2:20 = 140,000 ms
const under220 = allLaps.rows.filter(
  (r) => r.is_clean && r.lap_time_ms < 140000,
);

// only this object goes back to the model
result = {
  quickest_final_hour: bestCar,
  median_time: bestMedian,
  cars_under_220_clean: new Set(under220.map((r) => r.car_number)).size,
};

Plain JavaScript over 5,480 lap rows that never leave the database. Car 59, a 2:24.655 median in hour 8, and exactly one car under 2:20, all three confirmed afterwards by a separate SQL query.

Note the fields list, too: a lap row has 24 fields and most questions need a handful, so the tool takes a whitelist. The model picked its own four, 229 KB instead of 1.2 MB, and answered both halves of the question in that single call.

The MLE gotchas

Because I never used MLE before I also measured its impact. This is the same tool result, once fetched by a program inside MLE and once straight from PL/SQL:

PayloadDirect PL/SQLVia an MLE programOverhead
58 KB67 ms73 ms6 ms (9%)
147 KB107 ms128 ms21 ms (20%)
1.2 MB163 ms208 ms45 ms (28%)
2.3 MB230 ms297 ms67 ms (29%)

Starting a context is nearly free. A program that calls no tool at all (result = {x:1}) takes 20 ms the first time in a session and then settles at under 2 ms. The sandbox itself costs you nothing per call.

Watch the PGA. MLE memory lands in the session’s PGA, so this is the number to keep an eye on if many users chat at once. Tracking pga_max_mem for one session: 16 MB idle, 42 MB while hammering 1.2 MB tool payloads in a loop, 56 MB at 2.3 MB payloads, and 135 MB running complete agent turns. That last step is tempting to blame on JavaScript, but those turns pushed less data through the tools than the tool-only phase did, it is mostly the LLM request and response JSON (one turn carried 86k input tokens, about 338 KB of request body). The JavaScript heap holds whatever the program fetched and not much else.

Both directions point at the same conclusion: the sandbox is not the expensive part, the payload is. That is why a fields parameter was worth more than any tuning of the MLE side.

And one gotcha that is not about resources at all. xai.grok-4.3 wrapped its program in async function main() { ... } main();. Your code is already the body of an async function, so main() is started but never awaited: the program returns before the work finishes and result is never assigned. UC AI reported that honestly, the model retried four times with the same program — and then answered the question anyway, with numbers it had never received.

Tell the model explicitly that a failed program means it has no numbers, and to write straight-line top-level code. Left to itself it may fill the gap.

Wrapping up

Programmatic tool calling turns out to be a very natural fit for the Oracle database. MLE gives you a JavaScript runtime right next to your data, in a sandbox you can strip of database access entirely.

The feature ships with the next UC AI release (v26.3), will be documented in the Programmatic Tool Calling guide, and free and open source like the rest of it. It needs 23ai and a one-time install script that a DBA has to run, because creating the sandbox schema and the PURE environment needs privileges your app schema should not have.

Other Posts

Comments

Loading comments...
Homepage All Blogposts

AI disclaimer: I spend hours writing my blog posts by hand, adding my own thoughts and experiences to them. In my view, purely AI-generated content lacks that human depth and isn't worth publishing. I only use AI for research and editing assistance.