Replace Tool Loops with GPT-5.6 Native Tool Calling
On July 9, 2026, OpenAI moved the GPT-5.6 family (Sol, Terra, and Luna) to general availability across ChatGPT, Codex, and the API, and the Responses API picked up the feature that matters most for builders: Programmatic Tool Calling.

Introduction
On July 9, 2026, OpenAI moved the GPT-5.6 family (Sol, Terra, and Luna) to general availability across ChatGPT, Codex, and the API, and the Responses API picked up the feature that matters most for builders: Programmatic Tool Calling. Instead of calling your tools one round trip at a time, GPT-5.6 writes a JavaScript program that orchestrates them inside a sandboxed V8 runtime and returns only the final result. In this tutorial you will build a support-ticket enrichment pipeline both ways, the classic loop and the programmatic version, and measure the difference on round trips and tokens.
What a Classic Function-Calling Loop Costs
The standard pattern has not changed since function calling appeared: the model requests a tool call, your code executes it, you append the result to the conversation, and the model reads everything again to decide the next step. Every intermediate result re-enters the context window. Every step is a full network round trip plus a full model pass over a growing transcript.
For a workflow that touches three tools across a dozen records, that means a dozen round trips, each one re-reading every result that came before it. The model is not reasoning in those middle steps. It is playing courier between your own functions, and you are paying frontier-model prices per delivery.
What Programmatic Tool Calling Changes
With Programmatic Tool Calling, the model looks at the task, writes a JavaScript program that calls your tools as functions, and hands the program to a hosted, isolated V8 runtime. Loops, conditionals, filtering, and aggregation happen inside the sandbox. Intermediate results stay in the runtime instead of streaming back through the context window. Your application still executes every actual tool call, so your code keeps custody of the data, and the sandbox has no network access of its own.
OpenAI's docs frame the fit precisely: bounded, tool-heavy workflows that do not require fresh model judgment between each step. Reported token savings for multi-step workflows run from 38% to 63.5%, and because orchestration happens in an isolated runtime rather than in OpenAI's data infrastructure, the feature is Zero Data Retention compatible. That combination matters if you ship into companies with compliance teams (the kind who read data-processing agreements for sport).
Define the Tools the Program Will Call
The pipeline: given a batch of support tickets, pull each ticket, look up the customer's plan, and produce a priority-ranked summary of paying customers with open tickets. Three tools, deliberately boring.
import json
from openai import OpenAI
client = OpenAI()
TICKETS = {
"T-1001": {"customer_id": "C-9", "subject": "export hangs at 90%", "status": "open"},
"T-1002": {"customer_id": "C-4", "subject": "billing page 404", "status": "open"},
"T-1003": {"customer_id": "C-9", "subject": "webhook retries", "status": "closed"},
}
CUSTOMERS = {
"C-9": {"name": "Meridian Ltd", "plan": "enterprise", "mrr": 4200},
"C-4": {"name": "Bluepine", "plan": "free", "mrr": 0},
}
def list_tickets(status: str) -> list[dict]:
return [{"id": k, **v} for k, v in TICKETS.items() if v["status"] == status]
def get_ticket(ticket_id: str) -> dict:
return TICKETS[ticket_id]
def get_customer(customer_id: str) -> dict:
return CUSTOMERS[customer_id]
LOCAL_TOOLS = {"list_tickets": list_tickets, "get_ticket": get_ticket, "get_customer": get_customer}Swap the dicts for your real ticket system and CRM later; the orchestration code does not change, which is the whole point.
Run the Baseline Loop First
Measure before you optimize. The classic loop with plain function calling, counting round trips and tokens as it goes:
TOOL_DEFS = [
{"type": "function", "name": "list_tickets", "description": "List tickets by status",
"parameters": {"type": "object", "properties": {"status": {"type": "string"}},
"required": ["status"]}},
{"type": "function", "name": "get_ticket", "description": "Fetch one ticket by id",
"parameters": {"type": "object", "properties": {"ticket_id": {"type": "string"}},
"required": ["ticket_id"]}},
{"type": "function", "name": "get_customer", "description": "Fetch a customer record",
"parameters": {"type": "object", "properties": {"customer_id": {"type": "string"}},
"required": ["customer_id"]}},
]
TASK = ("List open tickets, fetch the customer for each, and return JSON: "
"paying customers only, sorted by MRR descending, "
"fields: ticket_id, subject, customer_name, mrr.")
def run_classic_loop():
input_items, round_trips, total_tokens = [{"role": "user", "content": TASK}], 0, 0
while True:
response = client.responses.create(
model="gpt-5.6-terra", input=input_items, tools=TOOL_DEFS)
round_trips += 1
total_tokens += response.usage.total_tokens
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
return response.output_text, round_trips, total_tokens
input_items += response.output
for call in calls:
result = LOCAL_TOOLS[call.name](**json.loads(call.arguments))
input_items.append({"type": "function_call_output",
"call_id": call.call_id, "output": json.dumps(result)})On this toy dataset the loop typically lands at 4 to 5 round trips. Scale the ticket table to 50 rows and the trips scale with it, with every customer record re-entering the context on every subsequent pass. Hold that number: the programmatic version below compresses the same task to two round trips by design, one carrying the generated program with its batched tool calls and one carrying the final answer.
Switch the Tools to Programmatic Calling
Two changes enable the feature: add programmatic_tool_calling to the tools array, and opt each function in by declaring it callable from the program via allowed_callers.
PTC_TOOLS = [{"type": "programmatic_tool_calling"}] + [
{**tool, "allowed_callers": ["programmatic_tool_calling"]} for tool in TOOL_DEFS
]
response = client.responses.create(
model="gpt-5.6-terra",
input=[{"role": "user", "content": TASK}],
tools=PTC_TOOLS,
)The model now emits a program item containing generated JavaScript instead of a chain of individual decisions. The program calls list_tickets once, loops over results, calls get_customer per ticket, filters out free plans, sorts by MRR, and builds the final JSON, all inside the V8 sandbox.
Handle Program-Issued Calls
Your executor changes shape: tool calls now arrive tagged with a caller, and you answer them the same way while the program runs to completion. Preserve the call_id linkage exactly as before.
def run_programmatic():
input_items, round_trips, total_tokens = [{"role": "user", "content": TASK}], 0, 0
while True:
response = client.responses.create(
model="gpt-5.6-terra", input=input_items, tools=PTC_TOOLS)
round_trips += 1
total_tokens += response.usage.total_tokens
pending = [item for item in response.output if item.type == "function_call"]
for item in response.output:
if item.type == "program":
print("--- generated program ---\n", item.code) # audit log
if not pending:
return response.output_text, round_trips, total_tokens
input_items += response.output
for call in pending:
result = LOCAL_TOOLS[call.name](**json.loads(call.arguments))
input_items.append({"type": "function_call_output",
"call_id": call.call_id, "output": json.dumps(result)})Print the program item in development and log it in production. It is the audit trail for what the model decided to do with your tools, and reading it is how you learn whether your tool descriptions are actually understood.
Cap What the Program Can Do
A program that can call your tools in a loop can call them in a very long loop. The sandbox is isolated, but your tools are real, so the guardrails belong in your dispatcher, not in the prompt.
class GuardedDispatcher:
def __init__(self, tools: dict, max_calls: int = 50):
self.tools, self.max_calls, self.calls = tools, max_calls, 0
def execute(self, name: str, arguments: str) -> str:
self.calls += 1
if self.calls > self.max_calls:
raise RuntimeError(f"tool budget exceeded: {self.calls} calls")
if name not in self.tools:
raise KeyError(f"tool not registered: {name}")
return json.dumps(self.tools[name](**json.loads(arguments)))Read-only tools can be generous. Anything that writes, sends, or deletes should either stay out of allowed_callers entirely or carry a budget of one.
Sequential Function Calling vs Programmatic Tool Calling
| | Sequential function calling | Programmatic Tool Calling | |---|---|---| | Availability | Chat Completions + Responses API | Responses API only, GPT-5.6 | | Round trips | One per tool decision | One per program batch | | Intermediate results | Re-enter context every step | Stay inside the V8 sandbox | | Token growth | Compounds with each step | Reported 38% to 63.5% lower | | Orchestration logic | Implicit in model turns | Explicit generated JavaScript you can log | | Data retention | Standard | ZDR-compatible | | Best for | Steps needing fresh judgment | Bounded, tool-heavy pipelines |
Limitations
The generated program is JavaScript, whatever your stack is. The sandbox has no network access, so tools remain your only I/O, which is a feature until you want streaming progress mid-program. Two runs on the same task can produce two different programs, so treat the program item as an artifact to log, not a contract to rely on. Workflows where each step should change the model's mind, real agentic investigation, still belong in the classic loop; forcing them into one program trades away exactly the judgment you were paying for. The feature only exists on the Responses API, so code still sitting on /v1/chat/completions sees nothing. And the parameter surface is days old: pin your SDK version and re-read the reference before shipping, because post-GA polish renames things. Most of the multi-step agent traffic running today is a for-loop billing you frontier prices to move JSON between your own functions, and this feature exists because OpenAI knows it.
The Final Working Example
Everything assembled into one runnable file, with the tools consolidated as lambdas to keep it short:
import json
from openai import OpenAI
client = OpenAI()
TICKETS = {
"T-1001": {"customer_id": "C-9", "subject": "export hangs at 90%", "status": "open"},
"T-1002": {"customer_id": "C-4", "subject": "billing page 404", "status": "open"},
"T-1003": {"customer_id": "C-9", "subject": "webhook retries", "status": "closed"},
}
CUSTOMERS = {
"C-9": {"name": "Meridian Ltd", "plan": "enterprise", "mrr": 4200},
"C-4": {"name": "Bluepine", "plan": "free", "mrr": 0},
}
LOCAL_TOOLS = {
"list_tickets": lambda status: [{"id": k, **v} for k, v in TICKETS.items()
if v["status"] == status],
"get_ticket": lambda ticket_id: TICKETS[ticket_id],
"get_customer": lambda customer_id: CUSTOMERS[customer_id],
}
TOOL_DEFS = [
{"type": "function", "name": "list_tickets", "description": "List tickets by status",
"parameters": {"type": "object", "properties": {"status": {"type": "string"}},
"required": ["status"]}},
{"type": "function", "name": "get_ticket", "description": "Fetch one ticket by id",
"parameters": {"type": "object", "properties": {"ticket_id": {"type": "string"}},
"required": ["ticket_id"]}},
{"type": "function", "name": "get_customer", "description": "Fetch a customer record",
"parameters": {"type": "object", "properties": {"customer_id": {"type": "string"}},
"required": ["customer_id"]}},
]
PTC_TOOLS = [{"type": "programmatic_tool_calling"}] + [
{**t, "allowed_callers": ["programmatic_tool_calling"]} for t in TOOL_DEFS
]
TASK = ("List open tickets, fetch the customer for each, and return JSON: "
"paying customers only, sorted by MRR descending, "
"fields: ticket_id, subject, customer_name, mrr.")
MAX_TOOL_CALLS = 50
def main():
input_items, trips, tokens, tool_calls = [{"role": "user", "content": TASK}], 0, 0, 0
while True:
response = client.responses.create(
model="gpt-5.6-terra", input=input_items, tools=PTC_TOOLS)
trips += 1
tokens += response.usage.total_tokens
for item in response.output:
if item.type == "program":
print("--- generated program ---\n", item.code)
pending = [i for i in response.output if i.type == "function_call"]
if not pending:
print(f"\nresult ({trips} round trips, {tokens} tokens):\n",
response.output_text)
return
input_items += response.output
for call in pending:
tool_calls += 1
if tool_calls > MAX_TOOL_CALLS:
raise RuntimeError("tool budget exceeded")
result = LOCAL_TOOLS[call.name](**json.loads(call.arguments))
input_items.append({"type": "function_call_output",
"call_id": call.call_id, "output": json.dumps(result)})
if __name__ == "__main__":
main()Run it, read the generated program it prints, then swap the two dicts for your ticket system and CRM clients.
When to Use It
Move a workflow to Programmatic Tool Calling when you can describe it in one sentence with no "and then decide" in the middle: enrich these records, reconcile these two lists, collect and aggregate these metrics. Keep the classic loop when each result should genuinely steer what happens next. The honest test is to read the program GPT-5.6 writes for your task; if it looks like code you would have written yourself, you have been paying per-round-trip for a script, and this is your refactor.
You might also like
Keep reading from the journal.
June 23, 2026AgenticAI
Too accurate to be comfortable
When personalization crosses into surveillance
July 13, 2026Engineering
A third of your ad spend reports to nobody
Server-side first-party collection makes the budget follow real numbers
July 13, 2026Extensions
You are paying to hire people you already employ
A canonical skills graph makes the payroll a queryable pipeline