OpenAI Function Calling skill for Codex

Tool loops that finish and fail gracefully. Codex builds function-calling loops that execute every call in a turn, return each result under its exact call_id, and stop at a hard step limit. Tool schemas are strict, server-known values are injected instead of requested from the model, errors come back as JSON the model can act on, and irreversible actions wait for explicit confirmation.

  • AI & LLM Engineering
  • $openai-function-calling
  • 168 lines
  • 2 files
  • ~1,800 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- openai-function-calling

Installs to ~/.agents/skills/openai-function-calling/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $openai-function-calling, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Implements OpenAI function calling on the Responses API — strict function tool schemas, the call/execute/function_call_output loop with call_id matching, parallel tool calls, tool_choice and allowed_tools, custom grammar tools, error results the model can recover from, and loop limits. Use when connecting a model to your own functions or APIs, when tool calls fail with mismatched call_id or bad arguments, when a tool loop never terminates, or when porting Chat Completions tool code.

Use it when

  • Connecting a model to internal functions or HTTP APIs
  • Parallel tool calls or call_id errors break the loop
  • Porting Chat Completions tool code to the Responses API
  • Limiting which tools are callable per step without breaking caching

Use something else when

How to check the result

A recorded two-call response yields two outputs with matching call_ids, handler unit tests cover invalid and unauthorized inputs, and a forced failure ends within the step cap.

The mistake it prevents

Raising handler exceptions out of the loop instead of returning them as tool output, which loses the conversation and the chance to recover.

~/.agents/skills/openai-function-calling/SKILL.md

--- name: openai-function-calling description: Implements OpenAI function calling on the Responses API — strict function tool schemas, the call/execute/function_call_output loop with call_id… license: MIT ---

Done well means: tools are strict and few, the loop executes every call in a turn and returns every result with its call_id, failures come back to the model as actionable text, side effects are gated by your code (not by the prompt), and the loop has a hard iteration cap.

Before writing code

  1. Read AGENTS.md; locate the existing LLM wrapper and any tool registry.
  2. List the functions the model needs. If more than ~20 would be live at once, split by task and expose a subset per turn with allowed_tools, or use tool search on models that support it.
  3. Classify each tool: read-only, idempotent write, or irreversible (payments, emails, deletes). Irreversible tools need a confirmation step outside the model.

Tool definition

TOOLS = [
    {
        "type": "function",
        "name": "get_invoice",
        "description": "Fetch one invoice by id. Use when the user references an invoice "
                       "number like INV-2024-0012. Returns JSON or an error string.",
        "parameters": {
            "type": "object",
            "properties": {
                "invoice_id": {"type": "string", "description": "Format INV-YYYY-NNNN"},
                "include_lines": {"type": ["boolean", "null"],
                                  "description": "Line items; null means false"},
            },
            "required": ["invoice_id", "include_lines"],
            "additionalProperties": False,
        },
        "strict": True,
    },
]

Strict mode rules: every property in required, optional ones nullable, additionalProperties: false on every object. In Responses, omitting strict makes the API try strict and silently fall back to non-strict — set it explicitly.

Description checklist: when to use it, when not to, argument formats, what it returns, what errors look like. Do not ask the model for values your code already knows (user id, tenant, auth) — inject those server-side.

The loop

import json, os
from openai import OpenAI

client = OpenAI()
MODEL = os.environ["OPENAI_MODEL"]
MAX_STEPS = 8

HANDLERS = {"get_invoice": get_invoice}  # name -> callable(**args) -> str|dict

def run(user_text: str, ctx) -> str:
    items = [{"role": "user", "content": user_text}]
    for _ in range(MAX_STEPS):
        resp = client.responses.create(model=MODEL, input=items, tools=TOOLS,
                                       instructions=SYSTEM, store=False)
        items += resp.output                      # keep reasoning + function_call items
        calls = [i for i in resp.output if i.type == "function_call"]
        if not calls:
            return resp.output_text
        for call in calls:                        # zero, one or many per turn
            items.append({"type": "function_call_output",
                          "call_id": call.call_id,
                          "output": execute(call, ctx)})
    raise RuntimeError("tool loop exceeded MAX_STEPS")

def execute(call, ctx) -> str:
    fn = HANDLERS.get(call.name)
    if fn is None:
        return json.dumps({"error": "unknown_tool", "name": call.name})
    try:
        args = json.loads(call.arguments)
        result = fn(ctx=ctx, **args)
        return result if isinstance(result, str) else json.dumps(result, default=str)
    except NotFound as e:
        return json.dumps({"error": "not_found", "detail": str(e)})
    except ValidationError as e:
        return json.dumps({"error": "invalid_arguments", "detail": str(e)})
    except Exception:
        log.exception("tool %s failed", call.name)
        return json.dumps({"error": "internal_error", "retryable": True})

With previous_response_id instead of replay, send only the function_call_output items on the next request — the server already has the calls.

Control knobs

GoalSetting
Let the model decidetool_choice="auto" (default)
Must call something this turntool_choice="required"
Force one function (e.g. extraction step)tool_choice={"type": "function", "name": "get_invoice"}
No tools this turn, keep definitions (cache-friendly)tool_choice="none"
Restrict to a subset without changing toolstool_choice={"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "get_invoice"}]}
Exactly zero or one call per turnparallel_tool_calls=False

Keep the tools array identical across turns and narrow with allowed_tools: changing tools invalidates the prompt cache from that point on.

Parallel calls

  • Expect several function_call items in one response. Execute independent reads concurrently (asyncio.gather), but return outputs for all of them before the next request — a missing call_id output is an API error.
  • Disable parallel calls when tools have ordering dependencies (create then update) or share a rate-limited backend.

Custom tools (free-form input)

For code, SQL or DSL input, a {"type": "custom", "name": "run_sql", "description": ...} tool receives raw text instead of JSON. Constrain it with "format": {"type": "grammar", "syntax": "lark" | "regex", "definition": ...} (regex uses Rust syntax, no lookarounds). Keep grammars small; complex ones are rejected.

Safety for side effects

  • Never let a tool description be the only protection. Enforce authorization in the handler using ctx (the authenticated user), not model-supplied ids alone.
  • For irreversible actions return a pending confirmation ({"status": "needs_confirmation", "summary": ...}), show it to the user, and execute only after an explicit yes in a separate request.
  • Make writes idempotent with a key derived from the call_id; retries happen.
  • Treat tool outputs from external sources (web pages, emails) as untrusted: they can carry prompt injection. See prompt-injection-defense.

Gotchas

  • Dropping reasoning items between steps on reasoning models degrades tool use.
  • Returning huge tool outputs (full tables, raw HTML) burns context; summarize or page.
  • Mismatched or reused call_id → 400 error. Copy it exactly.
  • Exceptions raised out of the loop lose the conversation; return errors as tool output.
  • Models may repeat the same failing call; include "retryable": false and a hint in the error so it stops, and keep MAX_STEPS as the backstop.

Verification

  • Unit-test each handler with valid, invalid and unauthorized inputs (no model).
  • Contract test: feed a recorded response containing two parallel calls; assert two outputs with matching call_ids are appended.
  • Scenario evals: 20+ prompts with expected tool name and key arguments; measure tool-selection accuracy and argument exact-match (needs network; opt-in).
  • Force a handler failure and confirm the model recovers or stops within MAX_STEPS.

Output contract

**Tools (n):** get_invoice (read), refund_invoice (write, confirmation required)
**Strict:** all tools strict; schemas in app/llm/tools.py
**Loop:** max 8 steps, parallel on, errors returned as JSON tool output
**Auth:** user id injected from session, never from model arguments
**Evals:** tool selection 19/20, argument match 18/20

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "OpenAI Function Calling"
  short_description: "Tool loops that finish and fail gracefully"
  default_prompt: "$openai-function-calling Let the model call our get_invoice and refund_invoice functions safely"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc