Skip to main content

SaXML4ADT

Save-as-XML for Agentic Development Toolkit. A command-line query store over a FileMaker Save a Copy as XML export: exact cross-references, readable scripts, impact analysis, a freshness model, and a web console — built so an agent doing FileMaker development with fm-cli can answer "what will this touch?" in milliseconds, without taking FileMaker's schema lock, and can tell when its answer is out of date.

Read-only. fm-cli stays the only writer. See docs/USE-CASES.md for the use cases this was built against and the FileMaker quirks found on the way.

The idea of a queryable store over FileMaker's own structural export came from Nuosis/FM2WEB_CLI (a DDR toolkit); SaXML4ADT is a from-scratch implementation around Save-as-XML and shares no code with it.

Prerequisites

Python 3.10+ pipx recommended
An ADT project a folder with adt.json naming the file — the ADT toolkit's own adt init, not this tool's saxml4adt init (see Install below) — and fm-cli on PATH with its keychain credentials for the file (fm --file=… --username=… --store-credentials)
FileMaker Server with the Data API on and an account with the fmrest extended privilege (the same account adt.json names is fine). Not hosted? See Working without a server below
Any coding agent Claude Code gets the freshness hook (saxml4adt init); agents without hooks (Codex, …) run fm-cli through saxml4adt fm …, which marks for them — saxml4adt init --agents writes that protocol into AGENTS.md

Install

pipx install saxml4adt                                          # or: pip install saxml4adt
cd ~/projects/my-adt-project                                   # the folder with adt.json (or an empty folder)
saxml4adt serve                                                # opens the browser on the Setup screen

The bleeding edge, ahead of the last PyPI release: pipx install git+https://github.com/mw777eds/SaXML4ADT.

The Setup screen does the rest. It shapes itself around what the project already has: with no file recorded it shows the adt connect command that records one, because that is ADT's file to write; with a hosted file it stores the Data API password and nothing else, the account coming from adt.json; with a local file there is no password to store and it says so. Then readiness checks, and buttons for the steps that can actually run — the console appears when the first build lands. The same steps from the terminal:

saxml4adt init                                                 # hook + .gitignore + readiness check
saxml4adt install                                              # once per FileMaker file (see below)
saxml4adt export --save                                        # first time: asks for the password, stores it
saxml4adt build
saxml4adt serve

adt.json is ADT's file, and ADT writes it: adt init --target fmnet://fms.example.com/MyFile in an empty folder, or adt connect <target> in a project that already has one, where <target> is an fmnet address or the path to a .fmp12. SaXML4ADT reads it and never writes it, so there is one record of which file a project means. A FileMaker account with Full Access is required, and only adt connect takes it — adt init has no --username, so record the account with adt connect --username <account> <target> after the project exists.

init writes the hook into .claude/settings.json, adds the export folder and the store to .gitignore (they are client data), and reports what is missing. install uses fm-cli to add one table (SaXML4ADT_Export), one layout of the same name, and three scripts in a SaXML4ADT folder — the machinery that runs Save a Copy as XML on the server and hands the files back through the Data API. Everything it adds is in saxml4adt/ops/install.ndjson for review. Put the server's full host name in adt.json (fmnet://fms.example.com/File) so the Data API certificate matches; a short alias can be mapped in ~/.config/saxml4adt/hosts.json ({"fms": "fms.example.com"}).

Credentials go in the OS credential store (macOS Keychain as a saxml4adt: host/file item; keyring elsewhere), or in SAXML4ADT_FM_PASSWORD for CI. saxml4adt credentials shows whether one is stored; --forget removes it and --username NEW --save stores the next one. To change the account fm-cli edits as, update username in adt.json (or run adt connect) and fm --forget-credentials / --store-credentials its own entry — SaXML4ADT never reads or writes FileMaker's keychain items.

Renamed from saxml. Earlier builds installed a saxml command; it is gone. An agent whose saxml … calls start failing with command not found mid-session is seeing the rename, not a broken install — switch to saxml4adt (same subcommands) and re-run saxml4adt init so the hook and .gitignore entries carry the new name.

Help

saxml4adt help lists topics, saxml4adt help scripts the commands in one, saxml4adt help steps one command with its options and examples (--json for agents). saxml4adt <command> --help still works. saxml4adt help concepts lists the mental models behind the store (marked vs unmarked, freshness, the hook loop, …); saxml4adt help concepts marked-vs-unmarked prints one.

The loop

saxml4adt export   →   saxml4adt build   →   query / serve
       ▲                                          │
       └── agent edits with fm → hook marks dirty ─┘
  • export runs the Save a Copy as XML and brings it into XML/SaveAsXML/ — hosted, the server produces it and every catalog file comes back through the Data API into <File>/; on this machine, FileMaker Pro writes it straight to disk as <File>_<stamp>/ (--catalogs ScriptCatalog,LayoutCatalog for a partial export, merged into the folder). The stamp is what keeps two runs apart: FileMaker will not overwrite an export folder that already exists, it uniques the name instead. --transport ssh rsyncs the server's Documents folder instead, for hosts you can reach. pull downloads an export the server already produced (after export --keep) without running it again. A note in the output when fewer files loaded than expected usually means Summary.xml — every structural catalog still loaded; it's not FileMaker skipping an empty one, and SaXML4ADT never reads Summary.xml anyway.
  • build loads the newest export into saxml4adt.sqlite in place: the build history, the change log and the dirty marks survive, and every object that changed since the previous build is reconciled against the marks (--fresh wipes). Seconds, even for a 70 MB export. Once the ingest has succeeded it deletes the older exports of that file — a full export is tens to hundreds of megabytes and nothing reads it again (--keep N, --no-prune, or SAXML4ADT_KEEP_EXPORTS=3 / =all for a default of your own).
  • serve opens the console: a map of catalogs, which objects are marked dirty / changed / changed-without-a-mark, drill-down to any object, the op that caused each mark, history by account, and the query log. It follows the store live. The server runs in the background — the terminal is free. Only one tab opens per project: running serve again while it's already up prints the existing URL and does not start a second server or open another tab; serve --restart stops and restarts it in place (same port) without opening a tab either — the console page you already have open detects the restart and reconnects on its own. serve --stop (or the title menu, which also closes that tab) ends it, and it quits by itself after 12 hours without a visitor.
  • Every query prints JSON with meta.stale — the open marks the result touches — and warns; --strict exits 3 on a stale answer, --max-age 30m exits 4 on an old export, and status reports catalogs whose source files are gone or newer than the build.

What it answers that fm cannot

saxml4adt steps <script> every step with 0-based index and 1-based line, options decoded by name — including the 51 step types fm exposes only as numbered slots (the XML's position="N" equals fm's slot index), and calcs fm renders as <Function Missing>
saxml4adt function <name> custom function bodies (fm cannot read them)
saxml4adt broken-refs dangling Field/Script/Layout/TO references, including ones on layout objects that FileMaker's own problems list omits
saxml4adt themes / styles <theme> every theme in the file and its named styles — the only names create:layout / addObjects will accept
saxml4adt duplicates script name collisions fm cannot address individually
saxml4adt taborder <layout> the tab order as FileMaker stores it — per-object positions, gaps left by deleted objects, and objects that have no position at all (everything fm adds), which is why fm-added fields land unpredictably; fm has no tab-order surface

What it answers faster

usages Table::field · refs-to layout X · callers / callees / triggers · describe layout|script|to|field · objects <layout> (with parent ids and enclosing path) · variables --problems / variable $$name (assignments vs reads, case-insensitive like FileMaker, spelling drift) · search (FTS5 over steps, calcs, comments, and object names, ranked ahead of content hits) · path TO-A TO-B · relations --suspicious · steps-of-type "Send Mail" --where "No dialog=Off" · fields-on <layout> · portals · unreferenced (never "unused" — read its caveat).

Freshness: an export is a snapshot

saxml4adt describe layout Settings                       # read
fm --file=… ops.ndjson                               # write with fm-cli
saxml4adt invalidate --from-ops ops.ndjson --as agent-2  # mark what the batch touched
saxml4adt describe layout Settings                       # -> meta.stale warns; --strict exits 3
saxml4adt refresh ~/exports/MyFile/SaveAsXML --catalogs LayoutCatalog   # re-ingest only that catalog
  • invalidate <kind> <name> marks one object; --from-ops marks every write target in an fm-cli batch; invalidate catalog ScriptCatalog marks a whole catalog.
  • ops.ndjson (also what impact preflights, below) is fm-cli's own batch format — one write per line, e.g. {"op":"create:field","table":"PRF__Preferences","name":"smtpAddress","type":"text"}.
  • --max-age 2h refuses to answer from an export older than that (exit 4).
  • refresh --catalogs A,B replaces only those catalogs from a new (possibly partial) export and clears their marks. FileMaker can produce a partial export: the Save a Copy as XML script step takes options JSON {"catalogs_included":["ScriptCatalog"],"split_catalogs":true}.
  • hooks/saxml4adt-invalidate-after-fm.sh is a Claude Code PostToolUse hook that runs invalidate --from-ops automatically after any fm … command, so the discipline does not depend on the agent remembering.
  • Hook discipline that is still on the agent: write ops to a file (ops on stdin/heredoc are invisible to the hook, which then marks every writable catalog dirty), and export SAXML4ADT_ACTOR so marks and touched --by can tell agents apart. The hook skips --dry-run and batches fm rolled back.
  • Every object row records modified_by, modified_at and its modifications counter from the XML, so with an agent-specific FileMaker account a re-export also shows what that account touched.

History and drift

Every object row carries the XML's own modifications counter and modified_by/modified_at. When a later export is ingested (build or refresh), SaXML4ADT diffs those against the previous state and records what the export showed changed — then reconciles it against what agents said changed:

saxml4adt refresh XML/SaveAsXML --catalogs ScriptCatalog   # prints drift: changes / unmarked / marked_unchanged
saxml4adt drift            # last refresh: predicted, changed-but-unmarked, marked-but-unchanged
saxml4adt history --by agent-bot --since 2026-08-28   # observed changes over time, with who and whether it was marked
saxml4adt touched --by agent-bot                      # straight from the export's stamps, no history needed
saxml4adt builds

unmarked_change is the alarm this whole model exists for: three agents and a person share one file, and the failure that actually happens is a peer writing to a file you own without your knowing. unmarked changes mean someone edited without telling the store (an agent that skipped invalidate, or a person in FileMaker Pro). marked_unchanged means a mark was a false alarm or the write never landed. Give each agent its own FileMaker account and the export becomes its audit trail.

Every version, without keeping the XML

Each build also records what changed inside every object — attribute before/after, script steps as line hunks — in change_log. Nothing else is kept, so the store stays small, yet any version since the store's second build can be reconstructed by replaying the deltas backwards:

saxml4adt history --kind script --object "Nightly Sync"   # every build that changed it, with the deltas
saxml4adt show script "Nightly Sync" --at 3               # the script as build 3's export showed it
saxml4adt diff script "Nightly Sync" --from 3 --to 5      # attribute before/after + unified diff of steps
saxml4adt diff field "Contacts::email" --from 2           # …to current

Works for every kind (layout, layout_object, field, table_occurrence, value_list, custom_function, …); the console's object panel shows the same timeline.

Web-viewer persistent stores (ADT [<app>] and friends, under PersistentStoreCatalog) get the same treatment: each build records the payload's sha1, size, modification count, account and timestamp — never the payload — so a redeploy shows up as an ordinary change_log row (old/new sha1 + size) via history/show/diff, same as any other object. saxml4adt status also compares each ADT [<app>] entry's recorded sha1 against webviewer-apps/<app>/dist/index.html on disk and reports it under a webviewer block, so a stale deploy is visible without opening FileMaker.

Web console

saxml4adt serve                             # background server + browser; `serve --stop` ends it, 12 h idle timeout
saxml4adt serve --restart                   # stop + start in place, same port, no new tab (the open tab reconnects)
saxml4adt serve --foreground --port 8770    # in this terminal instead (Ctrl-C stops it)
saxml4adt export-web console.html           # self-contained snapshot with the data embedded

Home is a review feed: "since your last visit" — the builds you have not seen, then one row per changed object with a pill (✓ marked / ⚠ unmarked / a twin-export notice), the kind and name, one line of what changed (step hunks, moved objects, the attributes), and who touched it when. The name opens the object; the what-changed line opens it on the panel that shows that change. Mark all reviewed advances your last-seen build (kept per store in this browser), and show all builds → is History. The catalog map moved one click away, to the Catalogs tab and the rail.

From there: drill-down to objects with their marks, observed changes, steps, layout objects and references; a history view with the per-refresh drift bars and who-touched-what; the query log. Marks can be made from the page when served live.

Every interaction is one of three verbs, and no click loses your place: select answers in place (clicking an object in a layout preview outlines it and fills an inspector directly below — bounds, enclosure breadcrumb, theme style and local overrides, field binding, last change; ⌥-click adds more; in a script, click a step and shift-click another for a range), peek opens over the view and Esc restores it exactly, and navigate is the only thing that reroutes. Every list that names an object — references, where-used, a layout's objects, a variable's scripts — peeks first: a slide-over brief with the facts, its last change and its reference counts, and one Open → when you actually want to go there.

An investigation trail runs under the header: every deliberate navigation leaves a chip (kind + name), and clicking one returns to that stop with its scroll position intact — an agent's stop lands there too, in amber. Back means the previous chip; Home starts a fresh trail. It is session-local and sits above the browser's own Back, which keeps working.

The pane boundaries are drag handles (rail | list | detail); widths persist per browser and a double-click resets one. ☰ collapses the catalog rail to a one-column icon strip. docs/CONSOLE-IA.md is the full contract, including every hash parameter a deep link can carry.

Pins and agent navigation

📌 Pin on any selection adds an entry to your pins — layout objects, a range of script steps, a calc fragment — instead of replacing the last one. The header button shows a live count and peeks the list. Say "look at my pins" and the agent reads all of them in one pull with the pinned_items MCP tool; reading grays them into a short history rather than deleting them, so they stay re-askable until you clear them. Nothing is ever pasted into your prompt.

The other direction: an agent can put something on your screen with console_show, which drives the open tab to a view and shows a toast saying what it is showing and why. Your previous view and scroll are saved first, and Esc — or the ↩ Esc returns you chip that outlives the toast — puts you back.

MCP server

saxml4adt mcp speaks the Model Context Protocol over stdio (JSON-RPC 2.0, newline-delimited), so Claude Desktop, Claude Code, Cursor and any other MCP client can query the store directly — without shelling out to saxml4adt. It is hand-rolled (stdlib + the rest of this package only; no mcp package dependency) and read-only except for one tool, mark_dirty, which writes to the store's own freshness ledger (never to the FileMaker file — fm-cli stays the only writer of that).

It also serves the concepts as resources — saxml4adt://concept/the-hook-loop, saxml4adt://concept/marked-vs-unmarked and the rest, the same text saxml4adt help <name> prints. An agent that can call mark_dirty should be able to read what a dirty mark is, and these are listed before the store is opened, so they answer in a project that has not built anything yet.

saxml4adt mcp --print-config          # Claude Desktop snippet + the `claude mcp add` line, with an absolute --db path
saxml4adt mcp --db ~/projects/my-adt-project/saxml4adt.sqlite   # run it (a client normally launches this itself)

--print-config fills in an absolute path so the config works regardless of the client's working directory:

claude mcp add saxml4adt -- saxml4adt mcp --db /Users/you/projects/my-adt-project/saxml4adt.sqlite

or, in claude_desktop_config.json:

{"mcpServers": {"saxml4adt": {"command": "saxml4adt", "args": ["mcp", "--db", "/Users/you/projects/my-adt-project/saxml4adt.sqlite"]}}}

Tools — one per read command that matters for an agent mid-task, each taking the same option names as its CLI counterpart and returning the same {meta, …} envelope (so meta.stale still warns when a result touches a mark made since the export): overview, status, files, search, object (= describe), referenced_by (= refs-to, impact analysis), script (= steps), layout_objects (= objects), layout_html, styling, style_suggestions, variables, history, show_at (= show), diff, blame, perf, conventions_check (= conventions), unreferenced, query_log, and mark_dirty (= invalidate, the one write tool). Each result comes back as content: [{type: "text", text: <json>}] plus a matching structuredContent.

Two more talk to the console rather than the export:

  • pinned_items — the items the user pinned in the console for you to look at, each with a ready-to-read text block and the structured data behind it. Reading marks them read (they stay in the console, grayed); {"include_read": true} brings them back.
  • console_show(to, note) — put something on the user's screen: drives their open console tab to a view, announces itself in a toast, and leaves Esc pointing back at where they were. Reports whether a tab actually applied it, and returns the full URL for when none is open. Needs the console to be running (saxml4adt serve) and to be launched from the project folder.

Resources: saxml4adt://overview (the same JSON as the overview tool) and saxml4adt://layout/<name>.html — one per layout, the same rendered standalone-HTML preview layout-html writes to disk, with dummy sample data.

The server connects to the store lazily, so initialize / tools/list still answer before a build exists; a tool call against a missing store comes back as that one call's error, not a dead connection. Nothing but JSON-RPC ever reaches stdout — diagnostics go to stderr.

Layout snapshots (macOS)

saxml4adt snapshot captures real FileMaker Pro layouts as PNGs — ground truth to compare against layout-html's renders. Once per run it raises the file's document window to the front of FileMaker's own window list (see the requirements below — this step matters), then switches the live layout with AppleScript, captures the document window with Quartz + screencapture (works on an occluded window, steals no focus), and records the layout FileMaker actually loaded, since an OnLayoutLoad trigger can redirect.

saxml4adt snapshot "Contact Detail"                          # one layout -> ./Snapshots/Contact Detail.png
saxml4adt snapshot --all --out Snapshots/Empowered_Beginning --compare   # every layout + a side-by-side compare.html
saxml4adt snapshot "Contact Detail" "Invoice Detail" --file Empowered_Contacts --delay 2

Requirements:

  • macOS, with FileMaker Pro running and the target file already open (not just hosted — open in this copy of Pro).
  • Screen Recording permission for whichever app runs saxml4adt (Terminal, iTerm, etc.) — System Settings → Privacy & Security → Screen Recording.
  • The fmextscriptaccess extended privilege on the account signed in to the file, for AppleScript layout control. Granting it does not take effect on an already-open file — close and reopen it first.
  • go to layout and get name of current layout of database "X" both act on that database's FRONT window — not necessarily the document window a user would recognize. A hidden card window (this package's own "ADT MCP Server Connector" window is a real example) can be frontmost instead; when that happens, every switch and readback silently happens there while the visible document window never moves, so every captured PNG comes back byte-identical regardless of which layout was requested (current layout even reads back as matching, since it is honestly reporting the connector window's layout — the mismatch only shows up as pixels that never change). snapshot handles this itself: before the sweep it raises the document window it found via Quartz to the front of FileMaker's own window list (go to window, or a System Events AXRaise fallback if that verb isn't in FileMaker's dictionary — the fallback needs Accessibility automation permission granted to the host running this command), so every later switch/readback in the run actually targets it. If you ever see a run come back with every PNG hashing identically, this raise is what to check first.
  • A modal FileMaker dialog (a Show Custom Dialog step, the "Summarize" field dialog, …) blocking a layout switch is also handled automatically — it is detected, the app is activated and Escape is sent, and the switch is retried once before that one layout is given up on and the sweep moves on (see warning/dialog_layouts in the JSON summary). One bad layout — a dialog that doesn't clear, a timed-out AppleEvent, anything else — is always isolated to its own row; it never aborts the rest of the sweep.

--compare also renders each captured layout with layout-html --sample dummy into the output directory and writes <out>/compare.html: the FileMaker PNG and the HTML render side by side per layout, for eyeballing fidelity. It is a plain local file — nothing gets published anywhere.

The JSON summary reports, per layout, {layout, actual, png, width, height, warning?}, plus a top-level dialog_layouts list of any layouts that hit a modal dialog. actual and warning only differ from the requested name when an OnLayoutLoad trigger redirected the view; --all skips layout names starting with . and keeps going past a failed switch, so one bad layout does not abort the run.

Working without a server

No FileMaker Server, no Data API, no credentials — a file open locally in FileMaker Pro (FM Starting Point, or any solution you have open on your own machine) is enough, and the console runs against the resulting store exactly the way it does for a hosted project.

Turn FileMaker sharing on before you start. ADT reaches a file through FileMaker's own sharing, so a file open in Pro with sharing enabled is the working shape — adt connect records it as fmnet://localhost/<File>, and fm connects to it as a client. That is what lets the file stay open in Pro while saxml4adt install writes to it. Without sharing, Pro holds a local file single-user and fm cannot open it at all: it fails with DBError 803 and the only way through is to close the file, install, and reopen it.

The account is not optional here either. A Full Access account must exist in the file and be recorded with adt connect --username <account>, even for a file FileMaker itself would open without one — everything SaXML4ADT does inside the file goes through fm-cli as that account.

There are then two ways to produce the export. By hand, with Save a Copy as XML in Pro, which needs nothing installed. Or with saxml4adt install, which puts an export script in the file; running it, the script sees that the file is not hosted anywhere else and writes the export straight into XML/SaveAsXML/<File>_<stamp>/ beside the .fmp12, which is where build already looks — it reads the newest. Both routes end in the same place; the script is worth the one-time install if you will re-export more than a couple of times.

The stamp is not decoration. FileMaker's Save a Copy as XML refuses to overwrite a destination folder that already exists and uniques the name instead — <File> Copy, then <File> Copy 1 — so a fixed destination meant every export after the first landed in a folder nothing was reading. A name that cannot repeat cannot be uniqued. build resolves an export by what a directory holds rather than by what it is called, so a <File> Copy 1 folder an older version left behind is still read, and still pruned once it is in the store.

pipx install saxml4adt
mkdir my-project && cd my-project
# put the .fmp12 here, open it in FileMaker Pro, and turn sharing on
adt init --target ./FM_Starting_Point.fmp12    # ADT records the file in adt.json
adt connect --username <account> <target>      # a Full Access account (a name, never a password)
saxml4adt init                                 # hook, .gitignore, MCP registration, agent skill

Keep the .fmp12 in the project folder: the export script writes beside the file, which is then also where build looks. Nothing in this path calls the Data API — the account exists so fm-cli can open the file, not to log in to a server. saxml4adt init installs the PostToolUse hook, adds saxml4adt.sqlite*, .saxml4adt/ and XML/SaveAsXML/ to .gitignore, registers the MCP server in .mcp.json and writes the agent skill; saxml4adt build will find an export under XML/SaveAsXML/ on its own even without ever running init, so this step is convenience, not a requirement.

saxml4adt serve's Setup screen (the one under Install above) has a first-class local mode: it names the file the project is pointed at, says there is no Data API and no password to store, and offers Build from files on disk as the step that applies here.

With the file open in Pro — locally, not just hosted-and-connected — run File → Save a Copy as XML…. Three of its options matter here (confirm the exact wording against your copy of Pro; these are the option names, not necessarily the on-screen labels):

  • split the catalogs into one file per catalog rather than one combined XML file — saxml4adt build reads either shape, but everything below assumes the split one
  • include the analysis / Database Design Report details — this is what makes tab order (saxml4adt taborder) available at all; skip it and every layout's object order goes unrecorded, not merely incomplete
  • keep binary data in the export — the switch that decides whether button icons and layout images are written out at all; turn it off and every layout preview in the console renders without its images and icons, though everything else about the object is still tracked normally

Save into XML/SaveAsXML/<File>/ next to the project folder — <File> is the live file's name without .fmp12, which is what FileMaker names the split files after, and has to match the file ADT recorded. That unstamped name is what saving by hand produces and build still reads it; only the installed export script stamps the folder. Then:

saxml4adt build
saxml4adt serve

Repeating the export and saxml4adt build later reconciles the store against whatever changed since — no watcher required, though saxml4adt watch works here too: it polls the export root, and a new stamped export folder appearing under it rebuilds the store from that one.

What this path cannot do: export, pull and credentials reach a server — the first two fetch an export the hosted file cannot hand over any other way, and the third stores a Data API password there is nothing here to log in to. Everything backed by fm-cli rather than the Data API does work: install, uninstall, verify-layouts and saxml4adt fm all open a local file by path. Nearly everything else behaves exactly as it does for a hosted project: every query command, refresh --catalogs (against a fresh manual export instead of a pulled one), mcp, watch, export-web, and snapshot (which drives FileMaker Pro directly over AppleScript and never touches the Data API, hosted or not).

saxml4adt doctor (and init's own readiness check) judges a project against what its own target actually needs, so a healthy local project reports "ok": true and exits 0. The checks that belong to the hosted path are listed separately under not_applicable, each with the reason it was skipped — a stored password and the Data API, for a local file. A requirement that does not apply is not a requirement you have failed to meet, and the distinction matters most here, where half the checklist is about a server you do not have.

Preflight an fm-cli batch

saxml4adt impact ops.ndjson

For each op: does the target exist in the export, is its name ambiguous, what references would a delete break, and do the table / occurrence / theme names it mentions resolve — all before fm opens the file and takes the schema lock.

Verify a write

saxml4adt build before/ --db a.sqlite && saxml4adt build after/ --db b.sqlite
saxml4adt diff-stores a.sqlite b.sqlite --script "Do Thing" --assert-only-changed 118   # exit 6 if anything else moved

Conventions

saxml4adt conventions checks every table, table occurrence, field, script, variable and custom function name against a naming-conventions file — saxml4adt.conventions.json, next to saxml4adt.sqlite in the project folder:

{
  "version": 1,
  "tokens": { "code": "[A-Z]{3}", "Code": "[A-Z][a-z]{2}", "lcode": "[a-z]{3}", "Name": "[A-Z][A-Za-z0-9]*", "name": "[a-z][A-Za-z0-9]*", "NAME": "[A-Z][A-Z0-9_]*" },
  "rules": {
    "table":            { "pattern": "{code}__{Name}",        "example": "CNT__Contacts" },
    "table_occurrence": { "base": "{code}__{Name}", "related": "{lcode}_{code}__{Name}", "example": "cnt_ADR__Addresses" },
    "field":            { "pattern": "{name}",  "example": "firstName" },
    "field_global":     { "pattern": "{name}_g", "example": "sessionId_g" },
    "field_key":        { "pattern": "_{name}", "example": "_contactId" },
    "script":           { "pattern": "{Name}", "example": "Contact_Create" },
    "variable":         { "pattern": "${name}", "example": "$contactId" },
    "variable_global":  { "pattern": "$${NAME}", "example": "$$CURRENT_USER" },
    "custom_function":  { "pattern": "{name}", "example": "trimAll" }
  },
  "ignore": ["^zz_", "^Global$"]
}

Each {token} in a pattern substitutes the named regex from tokens; every other character is literal (a $ in variable/variable_global matches a literal $). A rule may give several named alternatives instead of one pattern — table_occurrence above passes a name that matches either base (a table occurrence standing in for its own base table) or related (one added for a specific relationship context, prefixed with the other table's lcode) — and a name passes the rule if any alternative fully matches. ignore is a list of regexes checked against a name before it is checked at all (so zz_Archive__Old or a stock Global table are never flagged).

field_global covers fields with FileMaker's Global storage on; field_key covers fields whose name looks like a key (/^_/ or /id$/i, case-insensitive) and that are actually used on either side of a relationship — a customerNotes field ending in nothing key-shaped stays a plain field even if it happens to hold an ID-like value.

saxml4adt conventions --infer                  # guess one from the majority pattern already in the store
saxml4adt conventions --infer --write          # save the guess as saxml4adt.conventions.json (--force to overwrite)
saxml4adt conventions                          # check every name — the default once the file exists
saxml4adt conventions --kind field -f table    # one rule kind only
saxml4adt conventions --strict                 # exit 1 if anything violates

--infer classifies every existing name into a shape (AAA__Xxx, aaa_AAA__Xxx, xxx, Xxx, xxx_g, _xxx, XXX, Xxx_Xxx, xxx_xxx, or other), picks the majority shape per rule kind, and reports a confidence (the share of names that actually match it) plus the top-5 shape histogram — review those before --writeing an inferred file over a real one. --check (the default once a file exists and --infer is not given) reports every name that does not match, with expected (the alternatives it was checked against) and, where trivially derivable — camelCasing a field, uppercasing a global variable — a suggestion; table and table_occurrence violations get no suggestion, since picking the right 3-letter code is a human call.

Multi-file projects

A solution split across several .fmp12 files — a UI file plus one or more data files wired together with external data sources — builds into one store holding all of them, with references resolved across the file boundary: a field in a data file used only by layouts in the UI file no longer reads as unreferenced. saxml4adt build --file A --file B (repeatable) or a saxml4adt.project.json listing "files" in the project root drives which files are ingested; saxml4adt files lists what the store holds — objects per file, last build, and any unresolved external data source. Every query that names an object takes --file <name> to narrow it to one file; left off, the answer covers the whole project — or, once the store holds more than one file and one is marked primary, the primary file, with --all-files (alias --all) restoring store-wide scope for that one call. Single-file projects, and multi-file ones with no primary set, are unaffected — file_id 1 everywhere is exactly the old behavior, byte-identical.

Mark the development file with saxml4adt files --set-primary Empowered_Beginning (or a "primary" key in saxml4adt.project.json, which wins when both are set; --clear-primary removes the store's own mark). Once a primary is set, fm-cli write commands (variable --rename --apply, variables --fix-spelling --apply, uninstall) refuse to target any other file unless you pass --file <that file> explicitly — the primary is the file you are developing; the rest are treated read-only from here. export/install/uninstall --dry-run still work per-file regardless, since pulling or installing the export machinery on an old file is harmless.

build also guards against copy-twins: a file whose export shares more than 20% of its object uuids with a different file already in the store — the same file present twice under different names, e.g. an old export left next to a newer one — is held for confirmation (or, without a terminal, refused with exit 3) unless --allow-twin (alias --yes) is passed; saxml4adt files reports any such pair it finds, with the overlap percentage.

Full design in docs/MULTI-FILE.md.

Boundaries

  • Read-only. fm-cli remains the only writer.
  • Structure only. A table can be wired everywhere and hold zero records; this tool cannot see data, whether layout objects render, or business facts.
  • unreferenced ≠ unused. Manual-by-design scripts, external callers (Data API, WebDirect), and by-name runtime addressing are invisible here; targets that could be addressed dynamically are reported UNDECIDABLE.

Known FileMaker limits (found while building this)

  • Save-as-XML can omit objects fm-cli appended to an existing layout — the live layout has them, the export does not. saxml4adt verify-layouts cross-checks every layout's object count against the live file through fm-cli.
  • fm-cli writes a blank modifier name on everything it creates or updates, and the first export overwrites that blank on layouts with the export session (SaXML Export - <account> <pid>). Details: docs/bug-reports/.

Platforms

macOS and Windows (FileMaker Pro's platforms). macOS is what this is developed on; Windows uses the same code paths — credentials via Windows Credential Manager (keyring), the hook as saxml4adt hook --as "…", and the container transport, which needs no ssh. Windows has had no hands-on run yet: if something breaks, saxml4adt init prints the readiness check to include in an issue. The --transport ssh path assumes a Linux FileMaker Server Documents path (--remote-docs to override).

Releasing

Tag a version and push it: git tag vX.Y.Z && git push origin vX.Y.Z. The publish workflow builds the wheel and uploads it to PyPI through trusted publishing (project saxml4adt, environment pypi) — no tokens in the repo. v0.1.0 is on PyPI now, which is why Install above leads with pipx install saxml4adt; the next tag replaces it the same way.

Development

git clone https://github.com/mw777eds/SaXML4ADT && cd SaXML4ADT
python -m pip install -e '.[dev]'
python -m pytest -q                                   # 190 passed, 7 skipped — the skips need SAXML4ADT_TEST_EXPORT
SAXML4ADT_TEST_EXPORT=/path/to/an/export python -m pytest -q     # + those against a real export

Layout: saxml4adt/export.py (find and repair the XML), calc.py (tokenize calcs), steps.py (decode script steps), ingest.py (build the store), queries.py (every question), conventions.py (naming-conventions check + infer), cli.py (typer app: commands, export transports, hook, init, install), web.py + web/index.html (console), mcp.py (MCP stdio server).

Metadata

Release files for saxml4adt 0.1.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for saxml4adt 0.1.4
File Size Uploaded Provenance Actions
saxml4adt-0.1.4.tar.gz 525.0 kB With provenance Details for saxml4adt-0.1.4.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for saxml4adt 0.1.4
File Interpreter ABI Platform Actions
saxml4adt-0.1.4-py3-none-any.whl Python 3 none any Details for saxml4adt-0.1.4-py3-none-any.whl

Total release size: 913.8 kB

Release files / saxml4adt-0.1.4.tar.gz

Download URL saxml4adt-0.1.4.tar.gz
Size 525.0 kB
Tags Source
SHA-256 checksum
How to use checksums
557e1553ccd9a0133dad9c3605408201e0de02d9effae8349d0c5c2c75014263
BLAKE2b-256 checksum
How to use checksums
2e770a69cc320540cdbd8e9a50b9f98dac5d78c38ebf0355f39c2d4c4536d7a6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release files / saxml4adt-0.1.4-py3-none-any.whl

Download URL saxml4adt-0.1.4-py3-none-any.whl
Size 388.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9072c4898768f8ec1e9ed636289c9ef8631ab23b2192aa0da660da9d6ebf08f8
BLAKE2b-256 checksum
How to use checksums
6f2e4ceaaf453aa52e982f661add597d310391e489fab4d9869f553cce99940d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Security for saxml4adt 0.1.4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for saxml4adt-0.1.4.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page