diff --git a/.changeset/webviewer-container-upload.md b/.changeset/webviewer-container-upload.md
new file mode 100644
index 00000000..6766d440
--- /dev/null
+++ b/.changeset/webviewer-container-upload.md
@@ -0,0 +1,5 @@
+---
+"@proofkit/webviewer": minor
+---
+
+Implement `containerUpload` in `WebViewerAdapter`. Files are Base64-encoded and written by the add-on's `PK_container_upload` FileMaker script, which navigates by record ID with `Go to List of Records` in a new window so the Web Viewer's layout stays current. Adds a `container` adapter option for `scriptName`, `timeoutMs`, and `maxFileBytes`. Requires FileMaker Pro 22.0 or later and an add-on that includes the script. When no script answers in time the call rejects with the new exported `ContainerUploadTimeoutError`, which carries `outcome: "unknown"` because the timeout cannot stop a FileMaker script that may still commit the write. File names without an extension, and container field repetitions above 1, are rejected client-side.
diff --git a/apps/docs/content/docs/webviewer/containers.mdx b/apps/docs/content/docs/webviewer/containers.mdx
new file mode 100644
index 00000000..acd9e571
--- /dev/null
+++ b/apps/docs/content/docs/webviewer/containers.mdx
@@ -0,0 +1,313 @@
+---
+title: Container Fields
+description: Reading, displaying, and writing FileMaker container data from a Web Viewer app.
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+
+The Web Viewer bridge moves JSON strings, not binary. Container fields hold binary. So every container workflow in a Web Viewer app comes down to one of two choices: encode the bytes as Base64 and pass them through a FileMaker script, or keep the bytes in FileMaker entirely and drive a native script step from the web app.
+
+Uploads are handled for you by `containerUpload` and the add-on's script. Reads are not — you build those, so most of this page is about the FileMaker calcs involved.
+
+## Why the Data API container value is not enough
+
+Data API responses represent a container field as a URL, not as file content. Those URLs are tied to a Data API session, so putting one straight into `` inside a Web Viewer usually fails to render.
+
+## Uploading with `containerUpload`
+
+`WebViewerAdapter` implements `containerUpload` by Base64-encoding the file and handing it to a FileMaker script that decodes it back into the container field. The call looks the same as it does in a browser-hosted app:
+
+```ts
+await client.containerUpload({
+ containerFieldName: "Photo",
+ file, // a File from an
+ recordId: 3,
+});
+```
+
+This never goes through the Data API script and never batches, because the Data API uploads containers through a separate multipart endpoint that the `Execute FileMaker Data API` script step does not expose.
+
+
+ `containerUpload` needs all three of:
+
+ - `@proofkit/webviewer` **3.3.0** or later. Earlier versions throw
+ `Container upload is not supported in webviewer`.
+ - **FileMaker Pro 22.0** or later, for the `Go to List of Records` script step
+ the upload script uses to reach a record by ID.
+ - A ProofKit add-on that includes the `PK_container_upload` script. On an older
+ add-on the adapter rejects with `ContainerUploadTimeoutError` after the
+ configured timeout.
+
+
+Pass a `File`, not a bare `Blob`. FileMaker's `Base64Decode` needs a file name with an extension, and a `Blob` does not carry one:
+
+```ts
+// Blob from a canvas, fetch, or clipboard
+const file = new File([blob], "signature.png", { type: blob.type });
+```
+
+### Options
+
+```ts
+export const client = DataApi({
+ adapter: new WebViewerAdapter({
+ scriptName: "PK_execute_data_api",
+ container: {
+ scriptName: "PK_container_upload",
+ timeoutMs: 60_000,
+ maxFileBytes: 20 * 1024 * 1024,
+ },
+ }),
+ layout: "API_Assets",
+});
+```
+
+| Option | Default | Notes |
+| ------------------------ | ----------------------- | ----------------------------------------------------------------------------- |
+| `container.scriptName` | `"PK_container_upload"` | The add-on's container script. Override if your solution renamed it. |
+| `container.timeoutMs` | `60000` | Stops the promise hanging when no script answers. `0` waits indefinitely. |
+| `container.maxFileBytes` | `20971520` (20 MB) | Checked before encoding, so oversized files fail fast. `0` disables the check. |
+
+### A timeout is an unknown outcome
+
+The timeout ends the JavaScript wait. It cannot stop a FileMaker script that is already running, so a slow upload can commit *after* the promise rejects. `ContainerUploadTimeoutError` carries `outcome: "unknown"` for that reason — do not treat it as a confirmed failure.
+
+```ts
+import { ContainerUploadTimeoutError } from "@proofkit/webviewer/adapter";
+
+try {
+ await client.containerUpload({ containerFieldName: "Photo", file, recordId: 3 });
+} catch (error) {
+ if (error instanceof ContainerUploadTimeoutError) {
+ // The write may have landed. Refetch the record before telling the user it failed.
+ }
+}
+```
+
+Retrying is safe: the upload sets one field from a payload that fully determines the result, so sending the same file to the same record twice leaves the same end state. Refetch first anyway, so you do not upload again while the original script is still running.
+
+### Current limits
+
+- **Repetitions above 1 are rejected** client-side until the script supports them. Upload to repetition 1, or write the field with your own script.
+- Errors come back as `FileMakerError` with the real FileMaker code: `101` for a missing record, `102` for a field not on the layout, `105` for a missing layout, `306` for a stale `modId`.
+
+The rest of this page covers reading containers, and the script patterns to use when you need behavior beyond a straight field write.
+
+## Reading a container
+
+There is no `containerRead`. Reading is yours to build, because only you know which record to reach and what shape the screen needs.
+
+The FileMaker side is one script that returns the file as Base64. Start from the add-on's `FETCH CALLBACK TEMPLATE`, which already parses the request and sends the callback, and replace its business-logic block with two calcs:
+
+```FileMaker title="The only two calcs you need"
+Set Variable [ $result ; Value: JSONSetElement ( "" ;
+ [ "fileName" ; GetContainerAttribute ( Customers::Photo ; "filename" ) ; JSONString ] ;
+ [ "base64" ; Base64EncodeRFC ( 4648 ; Customers::Photo ) ; JSONString ]
+) ]
+```
+
+`GetContainerAttribute` gives you the file name, which the browser needs to pick a MIME type and to name a download. `Base64EncodeRFC` turns the bytes into something that survives a JSON payload.
+
+
+ `Base64Encode` inserts line breaks every 76 characters, which makes the string
+ invalid inside a `data:` URL. `Base64EncodeRFC ( 4648 ; field )` returns an
+ unbroken string.
+
+
+
+ `SendCallBack` uses `Perform JavaScript in Web Viewer`, which can only reach a
+ Web Viewer on the layout that is current when the step runs. If your script
+ uses `Go to Layout` to find the record, the callback silently fails and the
+ `fmFetch` promise never settles.
+
+ Reach the record in a `New Window`, then `Close Window` before sending the
+ callback. Script variables survive the window closing, so build `$result`
+ inside the window and use it after.
+
+
+On the web side, turn the response into a data URL:
+
+```ts title="containers.ts"
+import { fmFetch } from "@proofkit/webviewer";
+
+const MIME_BY_EXTENSION: Record = {
+ gif: "image/gif",
+ jpg: "image/jpeg",
+ pdf: "application/pdf",
+ png: "image/png",
+ webp: "image/webp",
+};
+
+function mimeFromFileName(fileName: string) {
+ const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
+ return MIME_BY_EXTENSION[extension] ?? "application/octet-stream";
+}
+
+export async function getCustomerPhoto(recordId: string) {
+ const { base64, fileName } = await fmFetch<{ base64: string; fileName: string }>(
+ "Get Container",
+ { recordId }
+ );
+
+ return {
+ dataUrl: `data:${mimeFromFileName(fileName)};base64,${base64}`,
+ fileName,
+ };
+}
+```
+
+
+ The type passed to `fmFetch` is not validated against what the script actually
+ returns. Validate with [zod](https://zod.dev) if the script and the app change
+ independently, and decide how an empty container should come back — an empty
+ `base64` string, or a `found` flag your script sets.
+
+
+### Rendering the result
+
+A data URL works directly as an image source or download target.
+
+```tsx title="CustomerPhoto.tsx"
+import { useQuery } from "@tanstack/react-query";
+import { getCustomerPhoto } from "./containers";
+
+export function CustomerPhoto({ recordId }: { recordId: string }) {
+ const { data } = useQuery({
+ queryFn: () => getCustomerPhoto(recordId),
+ queryKey: ["customer-photo", recordId],
+ });
+
+ if (!data) {
+ return
Loading photo…
;
+ }
+
+ return ;
+}
+```
+
+For anything large, convert to a blob URL instead. A data URL keeps the whole Base64 string in the DOM; a blob URL keeps one reference and lets the browser stream from memory.
+
+```ts title="blob-url.ts"
+export function base64ToBlobUrl(base64: string, mimeType: string) {
+ const binary = atob(base64);
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
+ return URL.createObjectURL(new Blob([bytes], { type: mimeType }));
+}
+```
+
+Call `URL.revokeObjectURL(url)` when the component unmounts, or the blob stays alive for the lifetime of the Web Viewer session.
+
+## Writing a container with your own script
+
+`containerUpload` covers a plain write to a container field. Write your own script when the upload needs more than that: validation, related-record creation, an audit trail, or several fields in one transaction.
+
+Copy the add-on's `PK_container_upload` script as a starting point rather than building the envelope from scratch. The calc that does the actual work is the mirror of the read:
+
+```FileMaker title="Base64 back into a container"
+Set Field [ Customers::Photo ; Base64Decode ( $base64 ; $fileName ) ]
+```
+
+
+ `Base64Decode ( text ; fileNameWithExtension )` stores the result as a named
+ file with the right extension. Without the second argument FileMaker stores an
+ untitled `.dat` file, and the container will not preview or export correctly.
+
+
+To Base64-encode the file in the browser, chunk it. `String.fromCharCode(...bytes)` on a multi-megabyte array blows the argument limit and throws:
+
+```ts title="upload.ts"
+const CHUNK_SIZE = 0x8000;
+
+export async function fileToBase64(file: File) {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ let binary = "";
+ for (let index = 0; index < bytes.length; index += CHUNK_SIZE) {
+ binary += String.fromCharCode(...bytes.subarray(index, index + CHUNK_SIZE));
+ }
+ return btoa(binary);
+}
+```
+
+The same two rules from reading apply: do the record work in a `New Window` and close it before the callback, and capture `Get ( LastError )` immediately after `Set Field` rather than after the commit, or a failed write reports success.
+
+After a successful write, invalidate the query that reads the container so the UI picks up the new file. See [Runtime Under the Hood](/docs/webviewer/runtime-under-the-hood) for the caching model.
+
+## Skipping the bridge entirely
+
+Base64 inflates payloads by roughly a third, and every byte crosses the bridge as a string. When files are large, or when the user is already sitting in FileMaker, it is often better to let FileMaker handle the bytes and only send a signal across.
+
+Use [`callFMScript`](/docs/webviewer/callFmScript) to trigger a script that runs `Insert File`, `Insert Picture`, or `Insert from URL` with FileMaker's own dialog, then refetch the record when the script reports back:
+
+```ts
+import { callFMScript } from "@proofkit/webviewer";
+
+callFMScript("Attach File To Customer", { recordId });
+```
+
+The script can call back into the app with a [Web Viewer command](/docs/webviewer/commands) once the user finishes, which keeps a multi-megabyte file out of the JSON payload completely.
+
+Other cases worth pushing to FileMaker:
+
+- Exporting a container to disk with `Export Field Contents`.
+- Fetching a remote file directly into a container with `Insert from URL`.
+- Generating a PDF from a FileMaker layout instead of rendering it in the browser.
+
+### Uploading directly with OttoFMS
+
+If the file is hosted on a server running [OttoFMS](https://docs.ottomatic.cloud/docs/ottofms/guides/webhooks/webhook-file-uploads), the web app can `POST` the file to the server as multipart form data instead of routing it through a script. No Base64, no bridge, and no FileMaker dialog for the user.
+
+Register the webhook with the **File Receiver** option enabled, then post to the file receiver endpoint:
+
+```ts title="otto-upload.ts"
+// Bundled into the Web Viewer, so treat this value as public. See the warning below.
+const OTTO_DATA_API_KEY = import.meta.env.VITE_OTTO_DATA_API_KEY;
+
+export async function uploadViaOttoFMS(recordId: string, file: File) {
+ const form = new FormData();
+ form.append("file", file);
+ form.append("recordId", recordId);
+
+ const response = await fetch(
+ "https://your.server.host/otto/filereceiver/YourFile.fmp12/customer-photos",
+ {
+ body: form,
+ headers: { Authorization: `Bearer ${OTTO_DATA_API_KEY}` },
+ method: "POST",
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`Upload failed: ${response.status}`);
+ }
+}
+```
+
+OttoFMS saves the upload under the server's `Documents/otto/{uuid}/` folder and runs your `OttoReceiver` script with an `uploaded_files` array in the payload. Each entry carries `fieldname`, `originalname`, `mimetype`, `size`, `destination`, `filename`, and `path`. The script uses `path` to pull the file into a container with `Insert File` or `Insert PDF`. Uploads are deleted after 24 hours, so move the file into the solution on receipt.
+
+
+ Anything the Web Viewer holds is readable by anyone who can open the file, so
+ treat this key as public. An OttoFMS Data API key inherits the privilege set of
+ the FileMaker account it was created with, which makes the privilege set the
+ only thing standing between a copied key and your data.
+
+ Create a dedicated account for it and lock the privilege set down as close to
+ write-only as FileMaker allows: create-only access on the one target table, no
+ access to any other table, and no script or layout access beyond what the
+ receiver needs. Never reuse a full-access or admin key here.
+
+
+Test the request from inside a real Web Viewer before committing to this path. A Web Viewer is not an ordinary browser page, and cross-origin requests do not always behave the same way there as they do in local development.
+
+## Sizing guidance
+
+- Return thumbnails in list views and fetch the full-size container only when a detail view opens.
+- Keep single transfers small. Multi-megabyte Base64 strings are slow to build in FileMaker, slow to parse in JavaScript, and hold up the bridge while they move.
+- Keep container fields off broad list layouts used by Execute Data API queries. See [Batching Data API Requests](/docs/webviewer/batching) for page-size tuning on container-heavy layouts.
+- Cache aggressively. A container rarely changes between renders, so a long `staleTime` in TanStack Query avoids repeat transfers.
+
+## Full web apps
+
+Outside a Web Viewer, containers go over HTTP:
+
+- [@proofkit/fmdapi](/docs/fmdapi) exposes `client.containerUpload({ containerFieldName, file, recordId })` for uploads.
+- [@proofkit/fmodata](/docs/fmodata) models containers with `containerField()`. Container fields cannot be included in `.select()`; read them with `.getSingleField()`.
diff --git a/apps/docs/content/docs/webviewer/meta.json b/apps/docs/content/docs/webviewer/meta.json
index 23432e9f..1698e6c4 100644
--- a/apps/docs/content/docs/webviewer/meta.json
+++ b/apps/docs/content/docs/webviewer/meta.json
@@ -12,6 +12,7 @@
"runtime-under-the-hood",
"data-access",
"filemaker-scripts-as-backend",
+ "containers",
"commands",
"initial-props",
"routing",
diff --git a/apps/docs/content/docs/webviewer/runtime-under-the-hood.mdx b/apps/docs/content/docs/webviewer/runtime-under-the-hood.mdx
index ebf29134..a2cface2 100644
--- a/apps/docs/content/docs/webviewer/runtime-under-the-hood.mdx
+++ b/apps/docs/content/docs/webviewer/runtime-under-the-hood.mdx
@@ -58,7 +58,7 @@ Common strategies include:
## Container data
-Data passed through the Web Viewer bridge is JSON. Container data usually needs to be Base64-encoded before it is included in a JSON payload.
+Data passed through the Web Viewer bridge is JSON. Container data usually needs to be Base64-encoded before it is included in a JSON payload. See [Container Fields](/docs/webviewer/containers) for the read, write, and skip-the-bridge patterns.
## Printing and PDFs
diff --git a/packages/webviewer/specs/container-upload-script.md b/packages/webviewer/specs/container-upload-script.md
new file mode 100644
index 00000000..a9da6f4d
--- /dev/null
+++ b/packages/webviewer/specs/container-upload-script.md
@@ -0,0 +1,248 @@
+# Spec: `PK_container_upload` FileMaker script
+
+Contract between the ProofKit add-on and `WebViewerAdapter.containerUpload()` in `@proofkit/webviewer`.
+
+Status: draft, for implementation of the FileMaker side. The TypeScript side will be built against this document.
+
+## Why a script is needed
+
+The Data API uploads containers through a separate multipart endpoint, not through its JSON action set. The `Execute FileMaker Data API` script step only exposes the JSON actions, so container upload cannot ride the existing `PK_execute_data_api` script. It needs its own script that decodes Base64 and writes the container field directly.
+
+## Why a separate script rather than a new action on `PK_execute_data_api`
+
+- Container upload is not a Data API action; overloading that script's contract muddies both.
+- It must never participate in request batching.
+- Payloads are far larger than a typical Data API call, so the two have different performance characteristics.
+
+## Requirements
+
+- **FileMaker Pro 22.0 (2025) or later**, for the `Go to List of Records` script step.
+- The `SendCallBack` script already shipped with the add-on.
+
+## Script name
+
+`PK_container_upload`
+
+Matches the existing `PK_execute_data_api` convention. The adapter will expose a `containerScriptName` option that defaults to this, so a solution can rename it.
+
+## Input
+
+The script is called by `fmFetch`, so `Get ( ScriptParameter )` is the standard envelope:
+
+```json
+{
+ "data": {
+ "layout": "API_Assets",
+ "recordId": 3,
+ "containerFieldName": "Photo",
+ "repetition": 1,
+ "fileName": "document.pdf",
+ "base64": "JVBERi0xLjQK...",
+ "modId": 7
+ },
+ "callback": {
+ "fetchId": "0f1c...",
+ "fn": "handleFmWVFetchCallback",
+ "webViewerName": "web"
+ }
+}
+```
+
+| `data` key | Type | Required | Notes |
+| -------------------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------- |
+| `layout` | string | yes | Layout name the record is reached through. |
+| `recordId` | number | yes | FileMaker internal record ID, same value the Data API returns. |
+| `containerFieldName` | string | yes | Field name only, no table occurrence prefix. Build the full name from `Get ( LayoutTableName )`. |
+| `repetition` | number | no | Defaults to `1`. |
+| `fileName` | string | yes | Original file name including extension. Must be passed to `Base64Decode`. |
+| `base64` | string | yes | Unpadded-safe RFC 4648 Base64, no line breaks. |
+| `modId` | number | no | When present, reject the write if the record's modification count no longer matches. |
+
+Take `webViewerName` from `callback.webViewerName`. Fall back to the add-on's default Web Viewer name when it is empty.
+
+## Output
+
+Return the same envelope shape as `PK_execute_data_api` so the adapter can reuse its existing error handling and `FileMakerError` type.
+
+Success:
+
+```json
+{
+ "messages": [{ "code": "0", "message": "OK" }],
+ "response": {}
+}
+```
+
+Failure:
+
+```json
+{
+ "messages": [{ "code": "102", "message": "Field is missing" }],
+ "response": {}
+}
+```
+
+`code` must be a **string**, matching the Data API convention. Use the real FileMaker error code from `Get ( LastError )` wherever one exists.
+
+### Error codes to return
+
+| Situation | Code | Message |
+| ---------------------------------------- | ----- | ------------------------------------------ |
+| Record ID not found | `101` | `Record is missing` |
+| Container field not on layout / not found | `102` | `Field is missing` |
+| Layout not found | `105` | `Layout is missing` |
+| `modId` mismatch | `306` | `Record modification ID does not match` |
+| Base64 decode produced an empty container | `500` | `Could not decode file data` |
+| Any other captured error | actual `Get ( LastError )` | FileMaker's text |
+
+## Behavior
+
+Every path must end by assigning `$result` and calling `SendCallBack`. The adapter treats a missing callback as a timeout, and rejects anything whose `messages[0].code` is not `"0"`.
+
+```text
+Set Error Capture [ On ]
+
+# 1. Parse the envelope
+Set Variable [ $json ; Value: Get ( ScriptParameter ) ]
+Set Variable [ $callback ; Value: JSONGetElement ( $json ; "callback" ) ]
+Set Variable [ $data ; Value: JSONGetElement ( $json ; "data" ) ]
+Set Variable [ $webViewerName ; Value: JSONGetElement ( $callback ; "webViewerName" ) ]
+If [ IsEmpty ( $webViewerName ) ]
+ Set Variable [ $webViewerName ; Value: "web" ] # the add-on's default object name
+End If
+Set Variable [ $layout ; Value: JSONGetElement ( $data ; "layout" ) ]
+Set Variable [ $recordId ; Value: JSONGetElement ( $data ; "recordId" ) ]
+Set Variable [ $fieldName ; Value: JSONGetElement ( $data ; "containerFieldName" ) ]
+Set Variable [ $fileName ; Value: JSONGetElement ( $data ; "fileName" ) ]
+Set Variable [ $base64 ; Value: JSONGetElement ( $data ; "base64" ) ]
+Set Variable [ $repetitionInput ; Value: JSONGetElement ( $data ; "repetition" ) ]
+Set Variable [ $repetitionType ; Value: JSONGetElementType ( $data ; "repetition" ) ]
+Set Variable [ $repetitionIsValid ; Value: ( IsEmpty ( $repetitionInput ) and IsEmpty ( $repetitionType ) ) or ( $repetitionType = JSONNumber and GetAsNumber ( $repetitionInput ) = 1 ) ]
+Set Variable [ $repetition ; Value: 1 ]
+Set Variable [ $modId ; Value: JSONGetElement ( $data ; "modId" ) ] # empty when absent
+
+# 2. Navigate by record ID, in a new window
+If [ not $repetitionIsValid ]
+ Set Variable [ $result ; Value: PK_error ( 500 ; "Only container repetition 1 is supported" ) ]
+Else
+ Set Variable [ $callerWindow ; Value: Get ( WindowName ) ]
+ Go to List of Records [ List of record IDs: $recordId ; Using layout: $layout ; Show in new window: On ; Animation: None ]
+ Set Variable [ $navigationError ; Value: JSONSetElement ( "{}" ; [ "code" ; Get ( LastError ) ; JSONNumber ] ; [ "message" ; Get ( LastErrorText ) ; JSONString ] ) ]
+ Set Variable [ $error ; Value: JSONGetElement ( $navigationError ; "code" ) ]
+ Set Variable [ $errorMessage ; Value: JSONGetElement ( $navigationError ; "message" ) ]
+ Set Variable [ $openedWindow ; Value: Get ( WindowName ) ≠ $callerWindow ]
+
+ If [ $error = 105 ]
+ Set Variable [ $result ; Value: PK_error ( 105 ; "Layout is missing" ) ]
+ Else If [ $error = 101 ]
+ Set Variable [ $result ; Value: PK_error ( 101 ; "Record is missing" ) ]
+ Else If [ $error ≠ 0 ]
+ Set Variable [ $result ; Value: PK_error ( $error ; $errorMessage ) ]
+ Else If [ Get ( FoundCount ) = 0 ]
+ Set Variable [ $result ; Value: PK_error ( 101 ; "Record is missing" ) ]
+ Else If [ IsEmpty ( JSONGetElement ( $data ; "modId" ) ) = False and Get ( RecordModificationCount ) ≠ GetAsNumber ( $modId ) ]
+ Set Variable [ $result ; Value: PK_error ( 306 ; "Record modification ID does not match" ) ]
+ Else
+ # 3. Decode, then write valid data while capturing errors after each step
+ Set Variable [ $fullFieldName ; Value: Get ( LayoutTableName ) & "::" & $fieldName ]
+ Set Variable [ $decoded ; Value: Base64Decode ( $base64 ; $fileName ) ]
+ If [ IsEmpty ( $decoded ) or $decoded = "?" ]
+ Set Variable [ $result ; Value: PK_error ( 500 ; "Could not decode file data" ) ]
+ Else
+ Set Field By Name [ $fullFieldName ; $decoded ]
+ Set Variable [ $fieldError ; Value: JSONSetElement ( "{}" ; [ "code" ; Get ( LastError ) ; JSONNumber ] ; [ "message" ; Get ( LastErrorText ) ; JSONString ] ) ]
+ Set Variable [ $error ; Value: JSONGetElement ( $fieldError ; "code" ) ]
+ Set Variable [ $errorMessage ; Value: JSONGetElement ( $fieldError ; "message" ) ]
+ Commit Records/Requests [ With dialog: Off ]
+ Set Variable [ $commitError ; Value: JSONSetElement ( "{}" ; [ "code" ; Get ( LastError ) ; JSONNumber ] ; [ "message" ; Get ( LastErrorText ) ; JSONString ] ) ]
+ If [ JSONGetElement ( $commitError ; "code" ) ≠ 0 ]
+ Set Variable [ $error ; Value: JSONGetElement ( $commitError ; "code" ) ]
+ Set Variable [ $errorMessage ; Value: JSONGetElement ( $commitError ; "message" ) ]
+ End If
+
+ If [ $error = 0 ]
+ Set Variable [ $result ; Value: PK_ok ]
+ Else
+ Set Variable [ $result ; Value: PK_error ( $error ; $errorMessage ) ]
+ End If
+ End If
+ End If
+End If
+
+# 4. Close the window on every path, then call back
+If [ $openedWindow ]
+ Close Window [ Current Window ]
+End If
+Set Variable [ $callback ; Value: JSONSetElement ( $callback ;
+ [ "result" ; $result ; JSONObject ] ;
+ [ "webViewerName" ; $webViewerName ; JSONString ] ) ]
+Perform Script [ Specified: From list ; "SendCallBack" ; Parameter: $callback ]
+```
+
+`PK_ok` and `PK_error ( code ; message )` above stand in for whatever you use to build the envelope. They must produce exactly:
+
+```json
+{ "messages": [{ "code": "0", "message": "OK" }], "response": {} }
+```
+
+```json
+{ "messages": [{ "code": "102", "message": "Field is missing" }], "response": {} }
+```
+
+Note `code` is a **string** in both. A numeric `0` will not match the adapter's check and a successful write would be reported as a failure.
+
+### Critical: never send the callback from another layout
+
+`SendCallBack` uses `Perform JavaScript in Web Viewer`, which can only reach a Web Viewer on the layout that is current when the step runs. If the script sends the callback while sitting on `$layout`, the call silently does nothing and the `fmFetch` promise on the JavaScript side never settles.
+
+`Go to List of Records` has a **Show in new window** parameter, so navigation and window creation are one step. Close that window before calling `SendCallBack`. Script variables survive the window closing, so build `$result` inside the window and use it afterward.
+
+### Window cleanup on every path
+
+Every exit path must close the window it opened, including error paths. If the window is left open, the user is stranded on a utility layout. Suggested approach: capture `Get ( WindowName )` before the step and only close if a new window actually opened, so a navigation failure that never created a window does not close the user's window instead.
+
+### Found set
+
+`Go to List of Records` replaces the found set in the window it targets. Because that is a new window, the user's found set in the original window is untouched. This is the main reason for the new-window approach beyond the callback constraint.
+
+### Error detection after navigation
+
+`Go to List of Records` ignores record IDs it cannot find and returns `101` or `401`. Check both `Get ( LastError )` and `Get ( FoundCount ) = 0` — a missing ID yields an empty found set rather than a hard failure.
+
+## Open questions for the FileMaker side
+
+1. **Repetition targeting.** `Set Field By Name` takes a calculated field name. Confirm whether a repetition can be addressed as `Table::Field[2]` in that expression, or whether a `Set Field` with an explicit repetition target and a branch is needed. If repetitions cannot be supported cleanly, say so and the adapter will reject `repetition > 1` client-side with a clear error.
+2. **`modId` check.** Confirm `Get ( RecordModificationCount )` is the right comparison for the Data API's `modId`.
+3. **Practical payload ceiling.** How large a Base64 string can move through `Get ( ScriptParameter )` before it becomes unusable? The adapter will enforce a client-side size limit and should use a real number.
+4. **Missing-script behavior.** If a solution has an older add-on without this script, what does `FileMaker.PerformScript` do — silent no-op, or user-facing error dialog? This determines whether the adapter needs a timeout to produce "update your ProofKit add-on" instead of a promise that hangs forever. See below.
+
+## Version detection
+
+Unlike the batching change, this is a brand-new script rather than a new key on an existing one, so the established `1708 / unknown key` detection in `adapter.ts` does not apply. An old add-on has no script to respond at all.
+
+Current client-side handling, pending the answer to open question 4: the adapter applies a timeout to `containerUpload` and, on expiry, rejects with `ContainerUploadTimeoutError`. If you would rather have positive detection, the script could accept `{ "action": "capabilities" }` and return a version number, which the adapter would call once per instance and cache.
+
+### The timeout is an unknown outcome, not a failure
+
+`Promise.race` ends the JavaScript wait. It cannot stop a FileMaker script that is already running, so a slow upload can commit *after* `containerUpload()` has rejected. The adapter therefore reports the timeout as an unknown outcome (`ContainerUploadTimeoutError` carries `outcome: "unknown"`) rather than claiming the script never ran.
+
+What makes this tolerable today is that the write is **idempotent**: the script sets one container field on one record from a payload that fully determines the result. Uploading the same file to the same record and field twice leaves the same end state, so a retry after a timeout cannot corrupt anything. Two things must stay true for that to hold, and both are requirements on the script:
+
+- The script must not append, version, or create related records as a side effect of the upload.
+- The script must not treat "container already populated" as an error.
+
+If a future version needs side effects, this contract needs a request identity: the adapter would send a client-generated `requestId`, the script would record it, and a repeated `requestId` would return the original result instead of re-running the write. Worth designing then, not now — but do not add side effects to the script without it.
+
+## Test cases
+
+- Valid upload to an empty container field.
+- Valid upload overwriting an existing container.
+- Non-existent `recordId` → `101`.
+- Field name not present on the layout → `102`.
+- Non-existent layout → `105`.
+- Stale `modId` → `306`, and the container is unchanged.
+- Malformed Base64 → non-zero code, container unchanged.
+- Uploading the same file to the same record twice → same end state, no duplicate side effects.
+- Every failure path closes the window it opened and still calls `SendCallBack`.
+- The user's original window keeps its layout, found set, and current record in every case above.
+- A `Get Container` round trip after upload returns the same bytes and the same file name.
diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts
index e1251caa..be503ebe 100644
--- a/packages/webviewer/src/adapter.ts
+++ b/packages/webviewer/src/adapter.ts
@@ -3,6 +3,7 @@ import { FileMakerError } from "@proofkit/fmdapi";
import type {
Adapter,
BaseRequest,
+ ContainerUploadOptions,
CreateOptions,
DeleteOptions,
FindOptions,
@@ -25,9 +26,37 @@ export interface WebViewerAdapterBatchOptions {
maxSize?: number;
}
+export interface WebViewerAdapterContainerOptions {
+ /**
+ * Name of the FileMaker script that writes container data.
+ * @default "PK_container_upload"
+ */
+ scriptName?: string;
+ /**
+ * How long to wait for the container script to call back. An add-on that
+ * predates the container script never calls back at all, so this is what
+ * stops the promise hanging forever.
+ *
+ * Expiry rejects with a {@link ContainerUploadTimeoutError}. It ends the
+ * JavaScript wait only; FileMaker keeps running and may still commit the
+ * write, so treat the outcome as unknown rather than failed.
+ *
+ * Set to 0 to wait indefinitely.
+ * @default 60000
+ */
+ timeoutMs?: number;
+ /**
+ * Reject uploads larger than this before spending time encoding them.
+ * Set to 0 to disable the check.
+ * @default 20971520
+ */
+ maxFileBytes?: number;
+}
+
export interface WebViewerAdapterOptions {
scriptName: string;
batch?: boolean | WebViewerAdapterBatchOptions;
+ container?: WebViewerAdapterContainerOptions;
}
interface ResolvedBatchOptions {
@@ -58,6 +87,109 @@ const DEFAULT_BATCH_MAX_SIZE = 20;
const BATCHING_DOCS_URL = "https://proofkit.dev/docs/webviewer/batching";
const LEGACY_BATCH_WARNING = `[ProofKit] ProofKit called the FileMaker script to execute Data API, but it did not support batching. Install the latest ProofKit add-on in your FileMaker file to get the updated script. Falling back to unbatched requests for this adapter. See ${BATCHING_DOCS_URL}`;
+const DEFAULT_CONTAINER_SCRIPT_NAME = "PK_container_upload";
+const DEFAULT_CONTAINER_TIMEOUT_MS = 60_000;
+const DEFAULT_CONTAINER_MAX_FILE_BYTES = 20 * 1024 * 1024;
+const BASE64_CHUNK_SIZE = 0x80_00;
+const CONTAINERS_DOCS_URL = "https://proofkit.dev/docs/webviewer/containers";
+
+/**
+ * Thrown when the container script does not call back in time.
+ *
+ * The timeout only ends the JavaScript wait; it cannot stop a FileMaker script
+ * that is already running. The upload may still commit afterwards, so this is
+ * an unknown outcome rather than a confirmed failure.
+ */
+export class ContainerUploadTimeoutError extends Error {
+ readonly scriptName: string;
+ readonly timeoutMs: number;
+ /** The write may still have completed in FileMaker. Verify before retrying. */
+ readonly outcome = "unknown" as const;
+
+ constructor(scriptName: string, timeoutMs: number) {
+ super(
+ `[ProofKit] The FileMaker script "${scriptName}" did not call back within ${timeoutMs}ms, so the outcome of this upload is unknown. The script may still be running and may still commit the container. Check the record before retrying. If every upload times out, the file's ProofKit add-on probably predates the container script, or the script has a different name than the adapter's container.scriptName option. See ${CONTAINERS_DOCS_URL}`,
+ );
+ this.name = "ContainerUploadTimeoutError";
+ this.scriptName = scriptName;
+ this.timeoutMs = timeoutMs;
+ }
+}
+
+async function withTimeout(promise: Promise, timeoutMs: number, error: () => Error): Promise {
+ if (timeoutMs <= 0) {
+ return await promise;
+ }
+
+ let timer: ReturnType | undefined;
+ const timeout = new Promise((_resolve, reject) => {
+ timer = setTimeout(() => {
+ reject(error());
+ }, timeoutMs);
+ });
+
+ try {
+ return await Promise.race([promise, timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+const blobToBase64 = async (blob: Blob): Promise => {
+ const bytes = new Uint8Array(await blob.arrayBuffer());
+ let binary = "";
+ for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE) {
+ binary += String.fromCharCode(...bytes.subarray(index, index + BASE64_CHUNK_SIZE));
+ }
+ return btoa(binary);
+};
+
+/**
+ * FileMaker's `Base64Decode` needs a file name with an extension, otherwise it
+ * stores an untitled `.dat` that will not preview or export correctly.
+ */
+const getUploadFileName = (file: Blob): string => {
+ const fileName = (file as File).name;
+ const trimmedFileName = typeof fileName === "string" ? fileName.trim() : "";
+ const extension = trimmedFileName.slice(trimmedFileName.lastIndexOf(".") + 1);
+
+ if (trimmedFileName !== "" && trimmedFileName.includes(".") && extension !== "") {
+ return trimmedFileName;
+ }
+
+ throw new Error(
+ `Container upload requires a file name with an extension, received ${JSON.stringify(trimmedFileName)}. Pass a \`File\` rather than a bare \`Blob\`, or wrap it: \`new File([blob], "photo.png", { type: blob.type })\`.`,
+ );
+};
+
+const resolveContainerRepetition = (repetition: string | number | undefined): number => {
+ if (repetition === undefined) {
+ return 1;
+ }
+
+ const value = typeof repetition === "string" ? Number(repetition) : repetition;
+ if (!Number.isFinite(value) || value < 1) {
+ throw new Error(`Container field repetition must be a positive number, received ${String(repetition)}`);
+ }
+ if (value > 1) {
+ throw new Error(
+ `Container field repetitions are not yet supported by the Web Viewer adapter (received repetition ${value}). Upload to repetition 1, or use a FileMaker script directly. See ${CONTAINERS_DOCS_URL}`,
+ );
+ }
+ return 1;
+};
+
+const resolveContainerOptions = (
+ container: WebViewerAdapterContainerOptions | undefined,
+): Required => {
+ const scriptName = container?.scriptName?.trim();
+ return {
+ maxFileBytes: Math.max(0, container?.maxFileBytes ?? DEFAULT_CONTAINER_MAX_FILE_BYTES),
+ scriptName: scriptName ? scriptName : DEFAULT_CONTAINER_SCRIPT_NAME,
+ timeoutMs: Math.max(0, container?.timeoutMs ?? DEFAULT_CONTAINER_TIMEOUT_MS),
+ };
+};
+
function normalizeBatchMaxSize(maxSize: number | undefined): number {
if (maxSize === undefined || !Number.isFinite(maxSize)) {
return DEFAULT_BATCH_MAX_SIZE;
@@ -156,6 +288,7 @@ function isLegacyBatchUnsupportedResponse(resp: BatchScriptResponse): boolean {
export class WebViewerAdapter implements Adapter {
protected scriptName: string;
+ private readonly containerOptions: Required;
private readonly batchOptions?: ResolvedBatchOptions;
private batchDisabledByLegacyScript = false;
private batchFlushInProgress = false;
@@ -166,6 +299,7 @@ export class WebViewerAdapter implements Adapter {
constructor(options: WebViewerAdapterOptions & { refreshToken?: boolean }) {
this.scriptName = options.scriptName;
this.batchOptions = resolveBatchOptions(options.batch);
+ this.containerOptions = resolveContainerOptions(options.container);
}
protected request = (params: {
@@ -521,7 +655,53 @@ export class WebViewerAdapter implements Adapter {
);
};
- containerUpload = (): Promise => {
- throw new Error("Container upload is not supported in webviewer");
+ /**
+ * Uploads a file into a container field through the ProofKit add-on's
+ * container script.
+ *
+ * The Data API uploads containers through a separate multipart endpoint that
+ * the `Execute FileMaker Data API` script step does not expose, so this never
+ * goes through the Data API script and never participates in batching. The
+ * file is Base64-encoded and decoded back into the container by FileMaker.
+ *
+ * Requires FileMaker Pro 22.0 or later on the FileMaker side.
+ *
+ * The write itself is idempotent: uploading the same file to the same record
+ * and field again produces the same result. A retry after a
+ * {@link ContainerUploadTimeoutError} is therefore safe, but check the record
+ * first, because the original script may still be running.
+ */
+ containerUpload = async (opts: ContainerUploadOptions): Promise => {
+ const { containerFieldName, file, modId, recordId, repetition } = opts.data;
+ const { maxFileBytes, scriptName, timeoutMs } = this.containerOptions;
+
+ const fileName = getUploadFileName(file);
+ const resolvedRepetition = resolveContainerRepetition(repetition);
+
+ if (maxFileBytes > 0 && file.size > maxFileBytes) {
+ throw new Error(
+ `Container upload is ${file.size} bytes, which exceeds the Web Viewer limit of ${maxFileBytes} bytes. Raise the adapter's container.maxFileBytes option, or move the file with a FileMaker script step instead. See ${CONTAINERS_DOCS_URL}`,
+ );
+ }
+
+ const payload: Record = {
+ base64: await blobToBase64(file),
+ containerFieldName: containerFieldName as string,
+ fileName,
+ layout: opts.layout,
+ recordId,
+ repetition: resolvedRepetition,
+ };
+ if (modId !== undefined) {
+ payload.modId = modId;
+ }
+
+ const resp = await withTimeout(
+ fmFetch(scriptName, payload),
+ timeoutMs,
+ () => new ContainerUploadTimeoutError(scriptName, timeoutMs),
+ );
+
+ this.handleDataApiResponse(resp);
};
}
diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts
index f17953fe..ebe1d7d6 100644
--- a/packages/webviewer/tests/adapter.test.ts
+++ b/packages/webviewer/tests/adapter.test.ts
@@ -7,6 +7,12 @@ vi.mock("../src/main.js", () => ({
fmFetch: vi.fn(),
}));
+const FILEMAKER_ERROR_101 = /101/;
+const MISSING_FILE_NAME_ERROR = /file name with an extension/;
+const UNSUPPORTED_REPETITION_ERROR = /repetitions are not yet supported/;
+const FILE_TOO_LARGE_ERROR = /exceeds the Web Viewer limit/;
+const UNKNOWN_OUTCOME_ERROR = /outcome of this upload is unknown/;
+
describe("WebViewerAdapter", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -673,4 +679,197 @@ describe("WebViewerAdapter", () => {
expect(payload.requests).toHaveLength(20);
}
});
+
+ describe("containerUpload", () => {
+ const makeFile = (contents: string, name = "photo.png") => new File([contents], name, { type: "image/png" });
+
+ const okResponse = () => Promise.resolve({ messages: [{ code: "0" }], response: {} });
+
+ it("sends a base64 payload to the container script without batching", async () => {
+ vi.useRealTimers();
+ vi.mocked(fmFetch).mockImplementation(okResponse);
+
+ const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" });
+ await adapter.containerUpload({
+ data: {
+ containerFieldName: "Photo",
+ file: makeFile("hello"),
+ recordId: 3,
+ },
+ layout: "API_Assets",
+ });
+
+ expect(fmFetch).toHaveBeenCalledTimes(1);
+ expect(fmFetch).toHaveBeenCalledWith("PK_container_upload", {
+ base64: btoa("hello"),
+ containerFieldName: "Photo",
+ fileName: "photo.png",
+ layout: "API_Assets",
+ recordId: 3,
+ repetition: 1,
+ });
+ });
+
+ it("includes modId only when supplied", async () => {
+ vi.useRealTimers();
+ vi.mocked(fmFetch).mockImplementation(okResponse);
+
+ const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" });
+ await adapter.containerUpload({
+ data: {
+ containerFieldName: "Photo",
+ file: makeFile("hello"),
+ modId: 7,
+ recordId: 3,
+ },
+ layout: "API_Assets",
+ });
+
+ expect(vi.mocked(fmFetch).mock.calls[0]?.[1]).toMatchObject({ modId: 7 });
+ });
+
+ it("honors a custom container script name", async () => {
+ vi.useRealTimers();
+ vi.mocked(fmFetch).mockImplementation(okResponse);
+
+ const adapter = new WebViewerAdapter({
+ container: { scriptName: "My Container Script" },
+ scriptName: "execute_data_api",
+ });
+ await adapter.containerUpload({
+ data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 1 },
+ layout: "API_Assets",
+ });
+
+ expect(fmFetch).toHaveBeenCalledWith("My Container Script", expect.anything());
+ });
+
+ it("throws a FileMakerError when the script reports a failure", async () => {
+ vi.useRealTimers();
+ vi.mocked(fmFetch).mockResolvedValue({
+ messages: [{ code: "101", message: "Record is missing" }],
+ response: {},
+ });
+
+ const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" });
+ await expect(
+ adapter.containerUpload({
+ data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 999 },
+ layout: "API_Assets",
+ }),
+ ).rejects.toThrow(FILEMAKER_ERROR_101);
+ });
+
+ it("rejects a Blob without a file name", async () => {
+ vi.useRealTimers();
+ vi.mocked(fmFetch).mockImplementation(okResponse);
+
+ const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" });
+ await expect(
+ adapter.containerUpload({
+ data: {
+ containerFieldName: "Photo",
+ file: new Blob(["hi"], { type: "image/png" }),
+ recordId: 1,
+ },
+ layout: "API_Assets",
+ }),
+ ).rejects.toThrow(MISSING_FILE_NAME_ERROR);
+ expect(fmFetch).not.toHaveBeenCalled();
+ });
+
+ it.each(["photo", "photo.", ".", ""])("rejects the file name %o for lacking an extension", async (name) => {
+ vi.useRealTimers();
+ vi.mocked(fmFetch).mockImplementation(okResponse);
+
+ const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" });
+ await expect(
+ adapter.containerUpload({
+ data: {
+ containerFieldName: "Photo",
+ file: new File(["hi"], name, { type: "image/png" }),
+ recordId: 1,
+ },
+ layout: "API_Assets",
+ }),
+ ).rejects.toThrow(MISSING_FILE_NAME_ERROR);
+ expect(fmFetch).not.toHaveBeenCalled();
+ });
+
+ it("rejects repetitions above 1 until the script supports them", async () => {
+ vi.useRealTimers();
+ vi.mocked(fmFetch).mockImplementation(okResponse);
+
+ const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" });
+ await expect(
+ adapter.containerUpload({
+ data: {
+ containerFieldName: "Photo",
+ file: makeFile("hi"),
+ recordId: 1,
+ repetition: 2,
+ },
+ layout: "API_Assets",
+ }),
+ ).rejects.toThrow(UNSUPPORTED_REPETITION_ERROR);
+ expect(fmFetch).not.toHaveBeenCalled();
+ });
+
+ it("rejects files larger than maxFileBytes before encoding", async () => {
+ vi.useRealTimers();
+ vi.mocked(fmFetch).mockImplementation(okResponse);
+
+ const adapter = new WebViewerAdapter({
+ container: { maxFileBytes: 4 },
+ scriptName: "execute_data_api",
+ });
+ await expect(
+ adapter.containerUpload({
+ data: { containerFieldName: "Photo", file: makeFile("way too long"), recordId: 1 },
+ layout: "API_Assets",
+ }),
+ ).rejects.toThrow(FILE_TOO_LARGE_ERROR);
+ expect(fmFetch).not.toHaveBeenCalled();
+ });
+
+ it("reports an unknown outcome when the script never calls back", async () => {
+ vi.mocked(fmFetch).mockImplementation(() => new Promise(() => undefined));
+ const timeoutMs = 500;
+
+ const adapter = new WebViewerAdapter({
+ container: { timeoutMs },
+ scriptName: "execute_data_api",
+ });
+ const result = adapter.containerUpload({
+ data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 1 },
+ layout: "API_Assets",
+ });
+ const assertion = expect(result).rejects.toThrow(UNKNOWN_OUTCOME_ERROR);
+
+ await vi.advanceTimersByTimeAsync(timeoutMs);
+ await assertion;
+ });
+
+ it("marks a timeout as an unknown outcome rather than a failed write", async () => {
+ vi.mocked(fmFetch).mockImplementation(() => new Promise(() => undefined));
+
+ const adapter = new WebViewerAdapter({
+ container: { timeoutMs: 500 },
+ scriptName: "execute_data_api",
+ });
+ const result = adapter.containerUpload({
+ data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 1 },
+ layout: "API_Assets",
+ });
+ const assertion = expect(result).rejects.toMatchObject({
+ name: "ContainerUploadTimeoutError",
+ outcome: "unknown",
+ scriptName: "PK_container_upload",
+ timeoutMs: 500,
+ });
+
+ await vi.advanceTimersByTimeAsync(500);
+ await assertion;
+ });
+ });
});