packages/fmdapi/skills/fmdapi-client/SKILL.md
DataApi factory, OttoAdapter (dk_ API key), FetchAdapter (username/password with token stores), CRUD methods: list, listAll, find, findOne, findFirst, maybeFindFirst, findAll, create, update, delete, get, executeScript, containerUpload, Standard Schema validation, portal data access, FileMaker Data API, layout-bound clients, schema inference
npx skillsauth add proofgeist/proofkit fmdapi-clientInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Start with typegen. Before manually configuring a DataApi client, use
npx @proofkit/typegen@betato generate type-safe clients from your FileMaker layouts. See the typegen-fmdapi skill. Manual setup below is only needed for advanced or non-typegen workflows.
Install the package:
pnpm add @proofkit/fmdapi
Requires OttoFMS installed on the FileMaker Server. No token management needed — the proxy handles sessions.
import { DataApi, OttoAdapter } from "@proofkit/fmdapi";
const client = DataApi({
adapter: new OttoAdapter({
auth: { apiKey: process.env.OTTO_API_KEY as `dk_${string}` },
db: process.env.FM_DATABASE,
server: process.env.FM_SERVER, // must start with https://
}),
layout: "API_Contacts",
});
API keys must start with dk_ (OttoFMS) or KEY_ (Otto v3). OttoFMS keys use the default HTTPS port with an /otto path prefix. Otto v3 keys use port 3030 by default (configurable via auth.ottoPort).
Authenticates with username/password. Manages Data API session tokens automatically.
import { DataApi, FetchAdapter } from "@proofkit/fmdapi";
const client = DataApi({
adapter: new FetchAdapter({
auth: {
username: process.env.FM_USERNAME,
password: process.env.FM_PASSWORD,
},
db: process.env.FM_DATABASE,
server: process.env.FM_SERVER,
tokenStore: fileTokenStore(), // IMPORTANT for production — see Common Mistakes
}),
layout: "API_Contacts",
});
The recommended path is to use @proofkit/typegen to generate layout-specific clients with full type safety and schema validation. The generated client file exports a pre-configured DataApi instance per layout:
import { CustomersLayout } from "./schema/client";
const { data } = await CustomersLayout.findOne({
query: { id: "==abc123" },
});
// data.fieldData is fully typed with your FM field names
Every DataApi client is bound to a single layout. All methods operate on that layout.
Find records:
// Standard find — returns { data: FMRecord[], dataInfo }
const response = await client.find({
query: { city: "Portland" },
});
// OR finds — pass an array of query objects
const response = await client.find({
query: [{ city: "Portland" }, { city: "Seattle" }],
});
// findOne — throws unless exactly 1 record found
const { data } = await client.findOne({
query: { email: "[email protected]" },
});
// findFirst — returns first record, throws if none found
const { data } = await client.findFirst({
query: { status: "Active" },
});
// maybeFindFirst — returns first record or null
const result = await client.maybeFindFirst({
query: { email: "[email protected]" },
});
// findAll — auto-paginates through all results (caution with large datasets)
const allRecords = await client.findAll({
query: { status: "==Active" },
});
// Suppress error on empty result set (FM error 401)
const response = await client.find({
query: { email: "[email protected]" },
ignoreEmptyResult: true, // returns empty array instead of throwing
});
List records (no find criteria):
// list — returns up to 100 records by default
const response = await client.list({
sort: [{ fieldName: "lastName", sortOrder: "ascend" }],
limit: 50,
offset: 1,
});
// listAll — auto-paginates (caution with large datasets)
const allRecords = await client.listAll();
Create:
const { recordId, modId } = await client.create({
fieldData: {
firstName: "Jane",
lastName: "Doe",
email: "[email protected]",
},
});
Update:
// recordId is FileMaker's internal record ID (from find/list/create responses)
await client.update({
recordId: 42,
fieldData: { email: "[email protected]" },
modId: 5, // optional optimistic locking
});
Delete:
await client.delete({ recordId: 42 });
Get by record ID:
const response = await client.get({ recordId: 42 });
// Direct execution
const result = await client.executeScript({
script: "Process Order",
scriptParam: JSON.stringify({ orderId: "12345" }),
});
console.log(result.scriptResult); // string returned by Exit Script
// Scripts attached to CRUD operations
const { recordId, scriptResult } = await client.create({
fieldData: { name: "New Record" },
script: "After Create Hook",
"script.param": JSON.stringify({ notify: true }),
// Also available: script.prerequest, script.presort (and their .param variants)
});
Portal data is returned on every record in the portalData property. Each portal row includes its own recordId and modId.
// Type-safe portal access with manual types
type TOrderRow = {
"Orders::orderId": string;
"Orders::orderDate": string;
"Orders::total": number;
};
type TPortals = {
portal_orders: TOrderRow; // key = portal object name on layout
};
const client = DataApi<TContact, TPortals>({
adapter: new OttoAdapter({ /* ... */ }),
layout: "API_Contacts",
});
const { data } = await client.find({ query: { id: "==123" } });
for (const row of data) {
for (const order of row.portalData.portal_orders) {
console.log(order["Orders::orderId"], order.recordId);
}
}
// Control portal pagination
const response = await client.list({
portalRanges: {
portal_orders: { offset: 1, limit: 10 },
},
});
const file = new Blob(["file contents"], { type: "text/plain" });
await client.containerUpload({
recordId: 42,
containerFieldName: "photo", // typed to field names if using schema
file,
containerFieldRepetition: 1, // optional, defaults to first repetition
});
Note: Schema validators are typically generated by
@proofkit/typegen. Manual schemas are only needed for non-typegen setups. If using typegen, customize via override files (seetypegen-setupskill).
The schema option accepts any Standard Schema compliant validator (Zod, Valibot, ArkType, etc.). When set, every read method validates and transforms each record's fieldData (and optionally portalData).
import { z } from "zod/v4";
import { DataApi, OttoAdapter } from "@proofkit/fmdapi";
const ZContact = z.object({
firstName: z.string(),
lastName: z.string(),
active: z.coerce.boolean(), // transform FM number to boolean
});
const client = DataApi({
adapter: new OttoAdapter({ /* ... */ }),
layout: "API_Contacts",
schema: {
fieldData: ZContact,
// portalData: { portal_orders: ZOrderRow }, // optional
},
});
// data.fieldData.active is now boolean, not number
const { data } = await client.findFirst({ query: { id: "==123" } });
If validation fails, the client throws with details about which fields mismatched. This catches FileMaker field renames at runtime before they corrupt downstream logic.
Wrong:
import { DataApi } from "@proofkit/fmdapi";
const client = DataApi({
layout: "Contacts",
server: "https://fm.example.com",
db: "MyDB.fmp12",
auth: { apiKey: "dk_abc123" },
});
Correct:
import { DataApi, OttoAdapter } from "@proofkit/fmdapi";
const client = DataApi({
adapter: new OttoAdapter({
server: "https://fm.example.com",
db: "MyDB.fmp12",
auth: { apiKey: "dk_abc123" as `dk_${string}` },
}),
layout: "Contacts",
});
v5 requires an explicit adapter instance. Connection config (server, db, auth) goes on the adapter, not DataApi. layout goes on DataApi.
Wrong:
const client = DataApi({
adapter: new FetchAdapter({
auth: { username: "admin", password: "pass" },
db: "MyDB.fmp12",
server: "https://fm.example.com",
// no tokenStore — defaults to in-memory
}),
layout: "Contacts",
});
Correct:
import { fileTokenStore } from "@proofkit/fmdapi/tokenStore/file";
// or for serverless:
// import { upstashTokenStore } from "@proofkit/fmdapi/tokenStore/upstash";
const client = DataApi({
adapter: new FetchAdapter({
auth: { username: "admin", password: "pass" },
db: "MyDB.fmp12",
server: "https://fm.example.com",
tokenStore: fileTokenStore(),
}),
layout: "Contacts",
});
Default memoryStore loses tokens on process restart, creating a new session each time. FileMaker allows max 500 concurrent sessions — serverless/edge deployments exhaust this quickly. Use fileTokenStore() for persistent servers or upstashTokenStore() for edge/serverless. OttoAdapter avoids this entirely.
Wrong:
// Saving recordId to your own database as a foreign key
const { recordId } = await client.create({ fieldData: { name: "Acme" } });
await myDb.insert({ fmRecordId: recordId }); // fragile!
Correct:
// Use a stable primary key field (e.g., UUID) from FileMaker
const { data } = await client.findOne({ query: { name: "Acme" } });
const stableId = data.fieldData.primaryKey; // UUID set by auto-enter
FileMaker's internal recordId can change during imports, migrations, or file recovery. Always use a dedicated primary key field (UUID or serial) for cross-system references. Only use recordId for immediate operations (update/delete) within the same request flow.
Wrong:
const client = DataApi({
adapter: new OttoAdapter({ /* ... */ }),
layout: "Contacts",
});
// Trying to query a different layout
await client.find({ layout: "Invoices", query: { status: "Open" } });
Correct:
const contactsClient = DataApi({
adapter: new OttoAdapter({ /* ... */ }),
layout: "Contacts",
});
const invoicesClient = DataApi({
adapter: new OttoAdapter({ /* ... */ }),
layout: "Invoices",
});
Each DataApi client is bound to one layout at creation. v5 removed per-method layout override. Create a separate client per layout. The adapter instance can be shared.
Wrong:
new OttoAdapter({
auth: { apiKey: "abc123-def456" }, // no prefix
db: "MyDB.fmp12",
server: "https://fm.example.com",
});
Correct:
new OttoAdapter({
auth: { apiKey: "dk_abc123def456" as `dk_${string}` },
db: "MyDB.fmp12",
server: "https://fm.example.com",
});
OttoFMS keys start with dk_, Otto v3 keys start with KEY_. The adapter uses this prefix to determine the connection method (port 3030 for KEY_, /otto path prefix for dk_). An unrecognized prefix throws at construction time.
Wrong:
import { z } from "zod";
const client = DataApi({
adapter: new OttoAdapter({ /* ... */ }),
layout: "Contacts",
zodValidators: {
fieldData: z.object({ name: z.string() }),
},
});
Correct:
import { z } from "zod/v4";
const client = DataApi({
adapter: new OttoAdapter({ /* ... */ }),
layout: "Contacts",
schema: {
fieldData: z.object({ name: z.string() }),
},
});
zodValidators was removed in v5. Use schema instead, which accepts any Standard Schema compliant validator. If upgrading from v4, re-run npx @proofkit/typegen to regenerate clients with the new option. The client throws at runtime if zodValidators is passed.
Wrong:
// Hand-writing types that duplicate your FM layout
type Contact = {
firstName: string;
lastName: string;
email: string;
};
const client = DataApi<Contact>({
adapter: new OttoAdapter({ /* ... */ }),
layout: "API_Contacts",
});
Correct:
// Use typegen-generated client which includes schema + types
import { ContactsLayout } from "./schema/client";
const { data } = await ContactsLayout.find({ query: { email: "[email protected]" } });
Manual types drift when FileMaker fields change, with no runtime protection. The typegen-generated client bundles a Standard Schema validator that catches field renames at runtime. Run npx @proofkit/typegen after any layout change. See typegen-setup skill for more details.
Wrong:
import { z } from "zod"; // v3
import { z as z4 } from "zod/v4"; // v4 in another file
// Both installed, schemas from different versions passed to DataApi
Correct:
// Use one version consistently. v5 typegen generates zod/v4 imports.
import { z } from "zod/v4";
Zod v3 and v4 have different Standard Schema implementations. Mixing them causes subtle type mismatches and potential runtime validation failures. The typegen tool generates zod/v4 imports by default. See typegen-setup skill for more details.
development
FileMaker WebDirect ProofKit Web Viewer runtime behavior refresh resilience session state localStorage browser resize reload same deployment embedded bundle avoid separate deployment avoid separate web server @proofkit/webviewer fmFetch callFMScript WebViewerAdapter WebDirect page refresh
development
webviewer fmFetch callFMScript WebViewerAdapter globalSettings setWebViewerName SendCallback window.FileMaker browser-only FileMaker Web Viewer script execution fire-and-forget FMScriptOption PerformScript callback fetchId handleFmWVFetchCallback
development
ENTRY POINT for @proofkit/fmodata projects. Generate TypeScript table schemas with entity IDs from FileMaker OData metadata using @proofkit/typegen. Covers proofkit-typegen-config.jsonc for OData mode, npx @proofkit/typegen setup, fmTableOccurrence generation, entity IDs (FMFID/FMTID), generated output structure, field exclusion, type overrides, InferTableSchema, env var configuration, OData prerequisites, fmodata privilege, and why typegen is required for entity ID correctness.
development
OData performance patterns for @proofkit/fmodata. Covers defaultSelect schema vs all, select() for minimal field fetching, select("all") override, pagination with top/skip, default 1000 record limit, batch operations for reducing round trips, entity IDs FMFID FMTID for rename resilience, null field query performance, getQueryString() debugging, relationship query performance testing, FileMaker OData optimization, avoiding OData service overload during testing.