---
title: "A File ID Is Not a File: Hardening PromptFax Intake Across ChatGPT and Claude"
description: "Why chat attachments are not portable document inputs, and how PromptFax now combines strict ChatGPT file parameters, immediate import, MCP Apps, and explicit fallbacks."
canonical_url: "https://promptfax.app/blog/a-file-id-is-not-a-file"
status: "published"
author: "Chad Hart"
date: "2026-08-29"
date_modified: "2026-08-29"
---

# A File ID Is Not a File: Hardening PromptFax Intake Across ChatGPT and Claude

Attaching a PDF to a chat looks like the easy part. The user can see the file. The model can often discuss it. Surely a remote MCP server can just use it too.

That assumption caused more trouble in PromptFax than the actual PDF handling.

The problem is that “the chat has a file” can describe several very different things: an opaque host ID, metadata about an attachment, a path inside the model host’s container, a temporary signed URL, or a real upload to storage controlled by the MCP server. Only the last two can become a PromptFax document, and even a temporary URL may stop working before the server fetches it.

We recently changed PromptFax file intake to make those boundaries explicit. ChatGPT now gets a strict, host-specific file parameter contract and the shortest automatic path. Claude and other compatible hosts get the same interactive PromptFax experience through the MCP Apps extension. Text-only clients still get a hosted upload path. Raw IDs, local paths, and attachment-shaped metadata are rejected instead of being mistaken for documents.

This post explains why that took more than adding a `files` array.

## The deceptively hard part: whose file is it?

An MCP server runs outside the model host. PromptFax cannot read ChatGPT’s private file store, Claude’s internal attachment store, or a container filesystem that belongs to either host. The visible attachment and the server-readable document are different objects until the host provides an authorized transfer mechanism.

We encountered several payloads that looked useful but were not:

- `file_abc123`: a ChatGPT file identifier. It identifies an object inside ChatGPT, but it is not a URL and PromptFax has no server-side API permission to resolve it.
- `/mnt/data/promptfax-smoke-test.pdf`: a path that may be valid inside a model host’s container. It does not exist in the PromptFax Cloud Run filesystem.
- `{ "name": "referral.pdf", "mime_type": "application/pdf", "size": 482193 }`: useful attachment metadata, but no bytes and no fetchable location.
- `https://.../download?[signed query]`: potentially usable, but short-lived and sensitive. Waiting too long can turn a valid handoff into an expired URL, and logging it can leak its bearer-like query string.

The first three cases share the same flaw: they describe a file without transferring it.

That distinction matters beyond PromptFax. Any remote agent tool that needs the original bytes—not merely a model-generated summary—has to answer the same ownership question.

## What changed for ChatGPT

OpenAI now defines a specific file-input contract for tools. A tool identifies its top-level file fields in `_meta["openai/fileParams"]`, and each file object uses four snake-case properties:

```json
{
  "download_url": "https://...",
  "file_id": "file_...",
  "mime_type": "application/pdf",
  "file_name": "referral.pdf"
}
```

The schema must declare all four properties. `download_url` and `file_id` are required; `mime_type` and `file_name` remain optional. PromptFax accepts up to five files on the optional `start_session.files` field.

![ChatGPT composer attachment reaches PromptFax automatically](../../public/blog/file-handling/chatgpt-automatic-attachment.webp)

*Figure 1. In a no-send test, the composer-attached fixture reached `start_session` and PromptFax confirmed the PDF was attached. No picker, quote, payment, or fax send was involved.*

That precision is important. Our generic compatibility parser still understands older aliases from non-OpenAI clients, but the ChatGPT descriptor advertises only the official snake-case fields. A permissive schema sounds friendly, yet it gives the host and the model more ways to produce attachment-shaped values that cannot be fetched.

When ChatGPT supplies a valid file parameter, PromptFax immediately downloads the temporary HTTPS URL and writes the bytes into PromptFax-owned storage. We keep the filename and MIME type as supporting metadata, but the imported object—not the host ID—becomes the document source of truth.

We deliberately do not try to resolve `file_id` on the server. OpenAI documents it as useful to the ChatGPT widget when the widget needs a fresh download URL. That is a host capability, not a portable credential for PromptFax.

## Failure now preserves the workflow

Temporary URLs fail. They can expire, be revoked, or become unreachable between the tool call and the fetch. Treating that as a failed session would force the user to start over even though nothing is wrong with the fax workflow itself.

PromptFax now creates and preserves the session, then returns the ChatGPT widget with a sanitized instruction to use **Choose PDF from ChatGPT**. The error does not include the signed URL, its query string, or host attachment metadata.

The resulting precedence is simple:

1. Use ChatGPT’s automatic `files[]` attachment when it is present and fetchable.
2. Use the ChatGPT file-library picker when the automatic handoff is missing or fails.
3. Use the hosted PromptFax upload page when an inline host surface is unavailable.

![ChatGPT file library fallback](../../public/blog/file-handling/chatgpt-picker-fallback.webp)

*Figure 2. The ChatGPT widget opens the host’s file library when automatic attachment is unavailable. The PromptFax session remains intact while the user chooses a file.*

This is less elegant than pretending every attachment is automatically portable. It is also much more reliable.

## Claude and the cross-host path

There is no reason for a generic MCP client to implement an OpenAI-specific `_meta["openai/fileParams"]` contract. The broader interoperability path is the MCP Apps UI extension.

PromptFax already had an inline upload application originally built for Claude. We generalized it instead of creating another host-specific integration. The server now checks for the negotiated `io.modelcontextprotocol/ui` extension and support for `text/html;profile=mcp-app`. A compatible client receives a neutral `ui://promptfax/app/...` resource and an `interactive_app` session. Older Claude clients retain a name-based compatibility fallback and their previous resource aliases.

Inside the app, the browser has the file bytes because the user selected the file there. The existing upload API transfers those bytes into PromptFax-owned storage. The remote MCP server never has to guess what a Claude attachment ID means.

![Claude inline MCP App upload](../../public/blog/file-handling/claude-inline-upload.webp)

*Figure 3. Claude renders the neutral PromptFax MCP App inline. The selected fixture has already been imported and the workflow is ready for a destination, with payment and sending still untouched.*

Clients that do not advertise an interactive UI capability receive no UI metadata. They continue through the hosted page or provide a fetchable HTTPS URL. Capability negotiation, rather than a growing list of host names, now selects the main path.

## One document boundary, three intake paths

The key architectural change is the convergence point. ChatGPT file parameters, an MCP App upload, and a hosted upload are different transport mechanisms, but they all become the same PromptFax-owned document before quoting.

![Cross-host PromptFax file intake flow](../../public/blog/file-handling/file-intake-flow.svg)

That gives us one invariant:

> If PromptFax cannot fetch or receive the bytes, it does not have a document.

Everything downstream—page inspection, quote creation, payment authorization, fax submission, status, and cleanup—can operate on that invariant without caring which host started the session.

## Why the stricter boundary helps

The obvious benefit is fewer clicks in ChatGPT. A composer-attached PDF can now reach PromptFax during `start_session` without opening the picker. The less obvious benefits are more important.

**Predictable failure behavior.** An expired URL no longer destroys the workflow, and an opaque ID fails before document creation rather than much later during quoting.

**Reduced credential exposure.** Signed URLs are fetched immediately, not stored as the canonical document location, and query strings are removed from errors and logs.

**Clear host responsibilities.** ChatGPT owns its file ID and file-library helpers. An MCP App host owns rendering the negotiated UI. PromptFax owns the imported document. Each boundary has a concrete transfer step.

**Portable workflow state.** The session survives a transport failure and can continue through another intake path. File acquisition is no longer confused with session creation.

**Less host-name coupling.** A new standards-compatible client can receive the neutral MCP App by advertising a capability. We do not need to add another `if host === ...` branch for every implementation.

**Better testability.** The descriptor can be asserted exactly, URL redaction can be covered with deterministic tests, and the host matrix has explicit expected behavior for ChatGPT, a generic MCP App client, legacy Claude, and a text-only client.

## What this does not solve

There still is no universal cross-host attachment object. OpenAI’s file parameter contract is specific to ChatGPT. MCP Apps standardizes an interactive UI transport, not automatic access to every file already attached to a conversation. Text-only clients still need a hosted upload or a real HTTPS URL.

Temporary URLs also remain temporary. Immediate import reduces the failure window; it cannot eliminate host-side expiration or network failure. That is why the picker and hosted page remain part of the design rather than temporary compatibility hacks.

Finally, a successful file import does not authorize a fax. PromptFax still requires a user-reviewed quote and Stripe authorization before sending. Our production tests for this feature were deliberately no-send checks until the normal release smoke.

## The broader lesson

The tempting abstraction is “attachments.” The useful abstraction is “a transfer of bytes into the system that must act on them.”

Once we used that boundary, the design became straightforward: make the shortest host-specific path precise, import immediately, negotiate the standards-based interactive path, preserve explicit fallbacks, and reject values that merely look like files.

That is a little more plumbing than accepting a string called `file_id`. It is also the difference between a demo that works in one chat and a document workflow that can survive real hosts, real expiration, and real user behavior.

## Sources

- [OpenAI plugin reference: Define file inputs](https://developers.openai.com/plugins/reference#define-file-inputs)
- [Model Context Protocol: Extensions overview](https://modelcontextprotocol.io/extensions/overview)
- [MCP Apps documentation](https://apps.extensions.modelcontextprotocol.io/)

## Related PromptFax resources

- [Seven Ways AI Agents Can Use PromptFax](/blog/seven-ways-ai-agents-can-use-promptfax)
- [PromptFax MCP setup](/mcp-setup)
- [PromptFax MCP tools](/mcp-tools)
