CodeGym /Courses /ChatGPT Apps /Errors, idempotency, and designing “safe” tools

Errors, idempotency, and designing “safe” tools

ChatGPT Apps
Level 4 , Lesson 4
Available

1. Errors and idempotency in ChatGPT App

In the classic web, many still live in the paradigm “user clicks a button → one HTTP request → one response.” In the LLM world, that’s long gone. The model may decide to call your tool multiple times, regenerate an answer after the user clicks Regenerate, ask follow-up questions, or run into a network error along the way. As a result, the same tool can be called two or three times with very similar arguments.

At the same time, any error suddenly has two consumers. On the one hand, the model needs a clear machine-readable explanation of what went wrong so it can fix the arguments and try again. On the other hand, there’s the user UI (the widget and the chat itself), where you need to show a human-friendly message and suggest next steps, not “Error: 500 (see logs).”

One more important point: classic architectures rarely assume people will massively click “regenerate answer,” thereby increasing the number of repeated requests (retries). In ChatGPT this scenario is the norm. Plus, the platform can itself re-invoke a tool when transient network problems occur. Therefore, the concept of idempotency in this ecosystem is not an optional extra but a baseline requirement, especially for tools that do something “for real” — create orders, charge money, send emails, etc.

This lecture is about how not to let a single failed tool call ruin the user’s experience and your production.

Insight

ChatGPT doesn’t pass arguments into your functions; it rather guesses a set of arguments against your schema. It looks at the JSON Schema, the dialogue context, and statistically picks values — and misses fairly often. Errors like “wrong type,” “missing required field,” “conflicting parameters” are a normal part of tool-call life, not force majeure. Public data and telemetry suggest such misses can easily account for up to ~30% of calls for complex schemas.

For the model, that’s fine: it treats your response as a signal “the arguments were bad” and simply tries again, possibly two or three times in a row, slightly changing the input. For you, it means something else: design every tool as if it’s almost guaranteed to be called multiple times with very similar parameters.

That’s exactly why idempotency matters. ChatGPT will keep trying to guess which parameters to use to call your functions. Two to three attempts per call is normal.

2. Safe widget configuration: text/html+skybridge and _meta

Before diving into purely server-side topics (errors, retries, idempotency), let’s close out one Apps SDK–specific UI security point: how to ensure your widget renders safely in the chat, and not like a “horrible page from the Internet.”

registerResource and the text/html+skybridge MIME type

From ChatGPT’s point of view, your widget is a special HTML resource that ends up in the ChatGPT client sandbox rather than directly in the user’s browser. To let the platform know it’s a widget and not just HTML, use the text/html+skybridge MIME type.

At the MCP/server level, you register a resource with something like (pseudo-TS):

// somewhere in the MCP server configuration
registerResource({
  name: "giftgenius-widget",
  path: "/widget",
  mimeType: "text/html+skybridge", // important!
});

This mimeType is a signal to the ChatGPT client: “this isn’t just HTML, it’s a component template for an embedded widget that must run in an isolated environment.” If you specify plain text/html, the platform may show raw HTML or refuse to render at all.

_meta and security controls: CSP, domain, and border

Next come the metadata sent along with the tool or resource response — _meta. With it, you control which external resources the widget can load, how it behaves visually, and even how the model will describe it.

Typical structure:

const toolResult = {
  content: "<!-- Widget HTML -->",
  _meta: {
    "openai/widgetCSP": "default-src 'self'; img-src https://cdn.example.com",
    "openai/widgetDomain": "https://chatgpt.com",
    "openai/widgetPrefersBorder": true,
    "openai/widgetDescription": "GiftGenius displays gift recommendations as cards."
  }
};

Let’s break down key fields.

  • openai/widgetCSP sets the Content Security Policy for the widget. This is your small browser firewall inside ChatGPT: you explicitly list where scripts, styles, images, XHR, etc. can be loaded from. The platform expects a strict policy without wildcard *; you need to explicitly list the domains used (the chat, your API, your CDN).
  • openai/widgetDomain sets the origin in which your widget will operate. Usually this is the ChatGPT domain; you don’t swap it for your site, you just declare how it should look in the isolated environment.
  • openai/widgetPrefersBorder is purely visual: whether to draw a border around the widget. For GiftGenius, it’s reasonable to keep a border to visually separate the recommendations block from regular chat messages.
  • openai/widgetDescription is a text description for the model. Instead of trying to “invent” an explanation by itself, the model can use this string when it tells the user what interface just opened. This reduces the risk of strange or verbose model commentary.

Practical takeaway: once you carefully set up mimeType and _meta, you get a secure, isolated UI that stays in its lane and behaves predictably for both the user and the platform. That’s the front-end security part done: the widget lives in a sandbox and only talks where you allow it. Next, we’ll focus on the server side — error types and how to describe them and make tools idempotent.

Insight: widget caching

ChatGPT caches the widget HTML at the moment the app is registered. A ChatGPT HTML widget is not a “live frontend,” but a fixed build artifact. When publishing the app (Store or Dev Mode), the platform reads the HTML resource (text/html+skybridge) and then always uses exactly that version. Any change — even one line of text or a margin in a card — effectively means a new release.

Hence the takeaway: changes to the HTML structure, slots, data-* attributes, and the structuredContentDOM contract are not a “quick fix,” but a full-fledged frontend migration. If today you render a list from items[], and tomorrow you switch to results[], the old widget won’t know: it will continue to receive the old JSON and behave incorrectly.

3. Error types in tool operation

Now to the meat: what kinds of tool errors exist and how they differ from a UX and backend perspective. It’s convenient to think in four layers of errors.

Input validation errors

The most basic level is when the input arguments don’t match the contract at all.

Examples for our demo app GiftGenius and its tool suggest_gifts (selecting gifts by interests and budget):

  • age is less than zero or greater than 120;
  • budget is negative;
  • required field relationship_type is missing;
  • budget_min > budget_max.

This also includes plain JSON that doesn’t match the schema. Ideally, the Apps SDK and JSON Schema will filter out “completely bad” calls before your code, but business validation (such as the relationship between budget_min/budget_max) still needs to be implemented by you.

Business logic errors

Here, the input seems valid, but you can’t produce a reasonable result according to domain rules.

Typical plot twists:

  • for the given interests and budget, you didn’t find a single gift;
  • the user exceeded the daily limit of selections;
  • the product the model wants to purchase is no longer available.

This isn’t “the server broke,” but normal, expected situations that should be presented to the user and the model in a digestible form, not as a 500 Internal Server Error.

External infrastructure errors

This layer is about “technical hell”: the database is unavailable, an upstream API times out, or your code threw an unhandled exception.

For example:

  • a request to the gift catalog returns 503 or never responds;
  • MongoDB suddenly pauses;
  • your gift filtering code divides by zero.

From a UX standpoint, this is often a reason to say “The service is temporarily unavailable, please try again later,” and sometimes to attempt a hidden retry. But it’s important not to fail silently and not to show the user a raw stack trace.

Platform/network errors

Finally, there’s a layer that can happen entirely outside your code: the tool call never arrived, the connection broke mid-response, a streaming scenario was interrupted. This happens more often than you think. For example, if you use a free tunnel, during peak hours its speed may drop so low that ChatGPT tool calls time out.

You can’t fully control this, but you can design tools and the widget so that repeated calls and interruptions don’t turn the system into chaos. That’s exactly why we talk about idempotency and careful error handling, not just “try/catch and forget.”

4. How to structure and return errors: for both the model and the UI

An important mindset shift: your error isn’t just what you logged to console.error. It’s part of the tool contract that both the model and the UI will consume.

Error structure

It’s usually convenient to follow a simple structure:

type ToolError = {
  code: string;        // "VALIDATION_ERROR", "NO_RESULTS", "UPSTREAM_TIMEOUT"
  message: string;     // human-readable or compact for the model
  retryable: boolean;  // whether it makes sense to try again
};

And you can wrap the tool result in a discriminated union:

type SuggestGiftsResult =
  | { ok: true; gifts: GiftCard[] }
  | { ok: false; error: ToolError };

Meanwhile, the MCP protocol also has a separate “this is an error” flag, but internally it’s useful to stick to your own format so the UI and model can consistently interpret what happened.

“Fail gracefully” strategy

Not every unfortunate situation should be a “hard” error. Sometimes it’s more useful to return an empty result without an error, just with an explanation.

For example, if no gifts are found, it’s reasonable to return ok: true, an empty array gifts: [], and some field like noResultsReason for the UI and the model, instead of "NO_RESULTS" as an error. Then the model can continue the dialogue: “I didn’t find anything within this budget, would you like to increase the budget or refine interests?”

But if the upstream API is completely down, that’s more like ok: false with code: "UPSTREAM_UNAVAILABLE" and retryable: true, so the model has a chance to try again later or with different parameters.

As a reminder from section 3, we have four layers of errors. Validation errors typically go as ok: false with retryable: false — the model shouldn’t repeat the same call with the same arguments. Business situations like “nothing found” are often modeled as ok: true with an empty result and an explanation. Infrastructure failures of external services look like ok: false with retryable: true, so the model can safely try again. Platform/network errors can occur before or after your code and often manifest as a repeated tool call — which is precisely why careful idempotency matters, as we’ll discuss next.

Don’t leak internal details

In server code, it’s tempting to just pass error.toString() through to the response. For LLM tools, that’s not a great idea: you’ll get garbage in the dialogue and potentially expose sensitive details (internal service URLs, stack traces, table names). The recommendation is to catch exceptions and convert them to compact error codes and tidy messages.

Minimal wrapper example:

try {
  const gifts = await loadGiftsFromCatalog(input);
  return { ok: true, gifts };
} catch (err) {
  console.error("suggest_gifts failed", err);
  return {
    ok: false,
    error: {
      code: "UPSTREAM_ERROR",
      message: "Catalog service is unavailable",
      retryable: true
    }
  };
}

The model sees a clean signal, the UI sees a clear text, and the details stay in the logs.

Displaying an error in the widget

From a React widget perspective, the task is simple: check ok, and if it’s false, show a friendly message and, if possible, a way to proceed.

function GiftResults({ result }: { result: SuggestGiftsResult }) {
  if (!result.ok) {
    return (
      <div>
        <p>Couldn't pick gifts: {result.error.message}</p>
        {result.error.retryable && <p>Try changing the parameters or retrying the request.</p>}
      </div>
    );
  }

  if (result.gifts.length === 0) {
    return <p>No gifts were found for these conditions. Try adjusting the budget or interests.</p>;
  }

  return <GiftCardsList gifts={result.gifts} />;
}

This is a case where a simple, honest message makes the UX much nicer than “something went wrong.”

We’ve already agreed some errors can be honestly marked as retryable: true and suggest the user “try again.” As soon as such retries appear (explicit in the UI or implicit on the platform side), the next question arises: what happens if the same tool is called twice with the same data? That’s where idempotency comes in.

5. Idempotency: protection against “one more identical call”

Now for the fun part. Formally, idempotency is a property of an operation whereby a repeated call with the same input does not change the system state and the result. Strictly speaking, this is about both the absence of repeated side effects and a consistent response. In ChatGPT Apps practice, we primarily care about the former: repeated calls shouldn’t corrupt data or create new entities, even if the response itself might differ slightly.

In the context of ChatGPT Apps, idempotency protects you from everything that happens with retries, Regenerate, and the model’s unpredictable logic.

Where idempotency matters most

Read-only tools are usually safe by default: no matter how many times you call suggest_gifts with the same parameters, you just get another list of gifts. Even if it differs slightly, it doesn’t change the system state and doesn’t create side effects.

Critical tools are those that modify external systems’ state:

  • creating an order (create_order);
  • charging a payment (charge_card, submit_payment);
  • sending emails and notifications (send_email, send_sms);
  • creating entities with side effects (e.g., reservations).

If such a tool is called twice in a row with nearly identical arguments, you may end up with duplicate orders, double charges, and other accounting “joy.”

The idempotency_key pattern

A classic approach: add an extra parameter idempotency_key — a string identifier of the operation. If a request with that key has already been processed successfully, the server does not perform the action again and returns the stored result.

An extended schema example for a hypothetical create_checkout_session tool in GiftGenius:

const CreateCheckoutSchema = {
  type: "object",
  properties: {
    giftId: {
      type: "string",
      description: "ID of the selected gift"
    },
    idempotency_key: {
      type: "string",
      description: "Unique operation key to protect against duplicates"
    }
  },
  required: ["giftId", "idempotency_key"]
} as const;

On the server, the handler does roughly the following:

async function createCheckoutSession(input: CreateCheckoutInput) {
  const existing = await db.checkoutSessions.findOne({ idempotencyKey: input.idempotency_key });
  if (existing) {
    return existing; // return the previous result
  }

  const session = await paymentProvider.createSession({ giftId: input.giftId });
  await db.checkoutSessions.insert({ idempotencyKey: input.idempotency_key, session });
  return session;
}

If the model, for some reason, calls the tool a second time with the same idempotency_key, the user won’t get a second charge, they’ll just see the same checkout.

Separating prepare and commit

For particularly sensitive actions (payments, irreversible changes), a two-phase approach is common: a separate tool for preparation (prepare_*) and a separate one for commit (commit_*).

For example:

  • prepare_order — checks item availability, calculates cost, returns a “draft order”;
  • commit_order — creates a real order and initiates payment based on the draft ID.

This design brings several benefits. First, you can make the first step fully idempotent: repeated prepare_order with the same parameters returns the same draft. Second, you can allow commit_order only after explicit user confirmation, which works well for both UX and security.

6. Safe tool design

Idempotency is necessary but not sufficient for safety. A lot depends on the very design of the toolset you give to the model.

The principle of least privilege

The idea is simple: each tool should do exactly what’s needed for the scenario and not a line more. You don’t need a single function do_anything_with_user_account that:

  • can read, update, and delete everything;
  • accepts a string operation and a JSON payload “with fingers crossed.”

It’s better to have separate, narrowly described tools:

  • get_user_profile;
  • update_user_preferences;
  • create_order;
  • cancel_order.

The same logic applies to GiftGenius: suggest_gifts only selects options; create_checkout_session knows nothing about canceling orders or changing the user’s email.

Separating “read” and “write” tools

A good pattern is to clearly separate tools that only read data from those that modify it. Catalog queries (search_products, suggest_gifts) are safe by themselves, even if the model overuses them. Meanwhile, create_order or charge_payment require more careful handling.

In such tools’ descriptions, explicitly state what they do and in what context they can be called. For example:

{
  "name": "create_checkout_session",
  "description": "Creates a new payment session for a single gift. Call ONLY after the user has explicitly confirmed their selection.",
  "parameters": { /* ... */ }
}

This isn’t a 100% safeguard (the LLM can still make mistakes), but at least you give it a clear signal about the risks.

Human-in-the-loop and confirmations

For truly “dangerous” actions, it’s useful to build a confirmation flow. For example, the model:

  1. First calls a tool that prepares the data for purchase and returns it in a UI-friendly form (gift name, price, shipping address).
  2. The platform shows a widget with a “Confirm purchase” button.
  3. Only after the click is the commit tool called, which performs the actual payment.

Thus you don’t allow the model to quietly place an order without the user’s involvement, even if it suddenly decides that’s a smart move.

Risk semantics in descriptions and annotations

In some platform versions, there are special annotations like destructiveHint that signal a tool may perform irreversible actions. Even if such fields don’t exist or are still unstable, you can encode this semantics right in the description and parameter names.

For example, instead of:

{
  "name": "delete_user_data",
  "description": "Deletes user data."
}

use:

{
  "name": "request_user_data_deletion",
  "description": "Marks the user account for deletion of personal data in accordance with the service policy. Use ONLY after the user has explicitly requested deletion."
}

And build a human confirmation UX around it.

7. A small practical enhancement to GiftGenius

Let’s tie this back to our GiftGenius demo app — an app for selecting gifts. Suppose we add another tool — create_checkout_session — so the user can not only pick a gift but also proceed to checkout.

From the JSON Schema and security perspective, we’ll do the following.

First, we add idempotency_key and a careful description:

const CreateCheckoutTool = {
  name: "create_checkout_session",
  description:
    "Creates a payment session for one selected gift. " +
    "Call only after the user has confirmed they want to buy this gift.",
  parameters: {
    type: "object",
    properties: {
      gift_id: {
        type: "string",
        description: "Identifier of the gift from the suggest_gifts result."
      },
      idempotency_key: {
        type: "string",
        description: "Unique operation key. Use the same key when retrying the call."
      }
    },
    required: ["gift_id", "idempotency_key"]
  }
} as const;

Second, on the server we implement an idempotent handler:

async function handleCreateCheckout(input: CreateCheckoutInput) {
  const existing = await db.checkout.findOne({ idempotencyKey: input.idempotency_key });
  if (existing) {
    return { ok: true, checkout: existing };
  }

  const checkout = await payments.createSession({ giftId: input.gift_id });
  await db.checkout.insert({ idempotencyKey: input.idempotency_key, ...checkout });

  return { ok: true, checkout };
}

Third, we account for errors:

try {
  return await handleCreateCheckout(input);
} catch (err) {
  console.error("create_checkout_session failed", err);
  return {
    ok: false,
    error: {
      code: "PAYMENT_PROVIDER_ERROR",
      message: "Failed to create a payment session. Please try again later.",
      retryable: true
    }
  };
}

And in the widget, we show a clear error state and, possibly, a “Retry” button at the UI level that initiates a new dialogue with the model.

Step by step, our cute training project stops being just a “demo toy” and slowly becomes something that you could, in theory, ship to production.

8. Common mistakes when handling errors and tool idempotency

Error No. 1: Error = just throw and 500.
If on any failure your tool simply throws an exception that turns into “something went wrong,” the model and the UI are left without information. The model doesn’t understand whether it should retry with different arguments, and the user doesn’t understand what to do next. It’s much better to return a structured error with a code, a brief message, and a retryable flag, while logging details on the server.

Error No. 2: No distinction between error types.
Mixing validation, business, and infrastructure errors into one bucket is a bad idea. As a result, a “nothing found” situation looks the same to the model and user as “the database is down.” This breaks UX and prevents the model from responding appropriately: instead of suggesting a query change, it will switch to “sorry, the service is down.” This hurts especially when you mix, for example, business errors and infrastructure errors from section 3.

Error No. 3: Non-idempotent operations in a world of retries.
Designing a create_order tool as if it will always be called exactly once is a direct path to duplicate orders, especially when the user eagerly clicks Regenerate or the connection breaks halfway through. If a tool has side effects, you almost always want to add an idempotency_key and store results so that a repeated call doesn’t create new entities.

Error No. 4: One monstrous “universal” tool.
Sometimes developers try to make one super tool with an action parameter that can do everything: search, create, update, and delete. For an LLM, this is almost guaranteed to make behavior unpredictable: it’s harder for the model to learn what to call when, and the consequences of mistakes become much more severe. It’s better to split into small, narrowly described, preferably read-only tools, and separately — carefully designed mutating tools with confirmations.

Error No. 5: Internal details leaking into responses.
Dumping raw stack traces or full exception texts into the model and UI is a classic engineering shortcut. It’s inconvenient for the user, can expose your internal system structure, and doesn’t help the model recover. Catch exceptions, map them to compact codes and simple messages, and keep all details in logs and monitoring.

Error No. 6: No linkage between errors and widget UX.
Often, the server side returns neat error codes, but the UI widget just falls into an endless spinner or an empty block. The user sees “nothing happened,” the model sees the tool call completed and continues the dialogue as if nothing happened. It’s much better to think through separate error and empty states, show understandable messages, and, if possible, suggest next steps (change parameters, try later).

Error No. 7: Ignoring the principle of least privilege.
Even if you implemented idempotency and good error handling, but still described a tool like execute_sql_anywhere that can do anything, the risk remains huge. The LLM can call it in the wrong context or with incorrect parameters. Each tool should be as narrow as possible and do exactly one clear action — especially when it involves money or personal user data.

1
Task
ChatGPT Apps, level 4, lesson 4
Locked
Idempotent tool add_favorite_jet with idempotency_key
Idempotent tool add_favorite_jet with idempotency_key
1
Task
ChatGPT Apps, level 4, lesson 4
Locked
Two-phase “Request quote”: prepare + commit, errors + idempotency + confirmation
Two-phase “Request quote”: prepare + commit, errors + idempotency + confirmation
1
Survey/quiz
App tools and <code><span class="text-user">callTool</span></code>, level 4, lesson 4
Unavailable
App tools and callTool
App tools and callTool: UI ↔ backend bridge
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION