Node-based visual workflow editor for federating UK Arts & Humanities research data services (UKRI/AHRC).
- Frontend only: React 19 + TypeScript + Vite, port 5174
- Node editor:
@xyflow/react(v12+) — import ONLY from@xyflow/react - No Service Worker / PWA / workbox
- API calls client-side via
fetch(). GBIF: direct. All others: same-origin proxy.
npx vitest run (config in vite.config.ts, include src/**/*.test.{ts,tsx}, jsdom) and npm run build (tsc -b && vite build) must BOTH stay green — the typecheck was repaired in refactor-v3 after a long period of drift; do not let it rot again. UnifiedRecord carries an [key: string]: unknown index signature by design (records are open — enrichment nodes add undeclared fields); adapter-output conformance is enforced at runtime by the fixture test, not the compiler. Test suites live in src/__tests__/:
utility unit tests, plus workflowIO.test.ts (save/load round-trip), runWorkflow.test.ts (Kahn wave ordering + failure
skipping, mocks nodeRunners), and fixtureConformance.test.ts — reads every public/fixtures/*.json from disk and asserts
records conform to UnifiedRecord (its ALLOWED_TOP_LEVEL list must be kept in sync with the interface).
| Command | Server | Port |
|---|---|---|
npm run dev |
Vite dev server (hot-reload) | 5174 |
docker compose up |
Express (server/index.mjs) |
3001 |
Both modes expose identical proxy endpoints and custom middleware — the single source of truth is server/proxies.mjs, imported by both vite.config.ts (dev) and server/index.mjs (prod). server/proxies.mjs is plain ESM by design so Node can execute it directly in-container without a build step.
Adding a new data source:
- Simple reverse-proxy → add an entry to
PROXY_TABLEinserver/proxies.mjs - Custom middleware → export a new connect-style function from
server/proxies.mjsand wire it into bothvite.config.ts(underconfigureServer) andserver/index.mjs(underapp.use)
| Prefix | Target |
|---|---|
/llds-proxy/* |
https://llds.ling-phil.ox.ac.uk/llds/* |
/ads-proxy/* |
https://archaeologydataservice.ac.uk/* |
/mds-proxy/* |
https://museumdata.uk/* |
/reconcile-proxy/* |
https://wikidata.reconci.link/* (307 redirect strips CORS — proxy required) |
/kcl-proxy/* |
https://api.ai.create.kcl.ac.uk/* (KCL OpenAI-compatible inference API) |
/ollama/* |
$OLLAMA_HOST (default http://localhost:11434) |
/bodleian-proxy/* |
https://digital.bodleian.ox.ac.uk/* |
/smg-proxy/* |
https://collection.sciencemuseumgroup.org.uk/* |
/vam-proxy/* |
https://api.vam.ac.uk/* |
/tgn-proxy/* |
https://vocab.getty.edu/* |
/getty-search-proxy/* |
https://www.getty.edu/* |
/nominatim-proxy/* |
https://nominatim.openstreetmap.org/* |
/hsds-proxy/* |
https://hsds.ac.uk/* |
/wdqs-proxy/* |
https://query.wikidata.org/* (SPARQL; proxy adds the descriptive User-Agent WDQS requires + Accept sparql-results+json) |
/url-proxy?url=<encoded>[&js=true][&wait=<strategy>] |
Custom middleware; simple path uses Node fetch(); js=true uses Puppeteer singleton (auto-reset on disconnected). Wait strategies: networkidle2 (default), networkidle0, domcontentloaded. |
/ads-library-search?q=<query>&size=<n> |
Custom middleware; two-step JSF session (GET ViewState → POST search) for the ADS Library catalogue. Returns extracted CDATA HTML for client-side parsing. |
/ads-catalogue-search?<qs> |
Custom middleware; Cloudflare bypass via warmed Puppeteer page holding cf_clearance. |
/llds-search?q=<query>&rpp=<n> |
Custom middleware; Puppeteer solves Anubis JS proof-of-work challenge. |
Model-dependent content truncation:
MODEL_CHAR_LIMITS = {
'arc:nano': 12_000, // small model — conservative limit
'arc:lite': 32_000, // mid-tier
'arc:nexus': 64_000, // large model
'arc:apex': 64_000, // apex — same as nexus for safety
}Use getContentMaxChars(model: string): number to get the per-model limit. Applied in both runners and components. In KCLFieldNode aggregate mode, per-value truncation is applied before concatenation to prevent oversized payloads.
Streaming in KCLFieldNode (component path only):
kclChat(..., onToken?: (token: string) => void)— ifonTokenis provided,stream: true; otherwisestream: false.- Streaming parser accumulates tokens via SSE (
data: {...}\n\nformat), firingonTokencallback for live UI preview. - Returns full accumulated text (not empty string) so callers can store the response in results.
- Runner (
runKCLFieldNode.ts) stays non-streaming; partial results achieved via per-recordsetNodeResultscalls.
Prompt recipes (src/hooks/usePromptRecipes.ts):
- Built-in recipes for standard and field-mode nodes (per-record and aggregate variants).
- User recipes stored in
localStoragewith versioning (nfcs_prompt_recipes+nfcs_prompt_recipes_version). - Hook exported:
usePromptRecipes() → { recipes, saveRecipe, deleteRecipe }.
src/
├── App.tsx # Canvas shell + state wiring only (~250 lines) — feature logic lives in hooks/ + components/
├── components/
│ ├── TopBar.tsx # Title, save/load, notes, grouping, mode toggles, Run All (author-mode + example dialog internal)
│ ├── Sidebar.tsx # Node palette: search, collapsible TaDiRAH groups, Experimental section
│ └── … # ChatSidebar (takes nodes/edges as props — no ReactFlowProvider
│ # exists, so RF hooks don't work there; appends a live CURRENT
│ # CANVAS lineage section to the system prompt per send),
│ # ConnectionSuggestions, modals, …
├── hooks/
│ ├── useWorkflowIO.ts # save/applyWorkflow/load + workflowId + loadError
│ ├── useGrouping.ts # group/ungroup + auto-resize effect (debounce/tolerance comments preserved)
│ ├── useCanvasConnections.ts # onConnect/onDrop/onConnectEnd, SINGLETON_TARGET_HANDLES, suggestion + handle-picker popups
│ └── useUpstreamRecords.ts # merges records from all data-handle edges
├── config/
│ ├── storageKeys.ts # STORAGE_KEYS — all localStorage key constants
│ ├── sidebarItems.ts # SIDEBAR_ITEMS, SIDEBAR_GROUPS, DEFAULT_COLLAPSED_GROUPS,
│ │ # ADVANCED_TYPES — single source of truth for the sidebar
│ └── nodeDefaults.ts # NODE_DEFAULTS factory record, KCL_API_KEY_NODES, findSharedApiKey
├── styles/
│ └── appStyles.ts # React.CSSProperties constants for App.tsx layout
├── types/
│ ├── UnifiedRecord.ts # Canonical inter-node data contract (schema.org annotated).
│ │ # Domain-specific GBIF fields live ONLY under gbif.* — no flat copies.
│ │ # periodStart/End/Name stay top-level (cross-service: ADS/ARIADNE/HSDS).
│ └── AppNode.ts # AppNode union type + inline *NodeData interfaces
├── store/resultsStore.ts # Out-of-band Map store + version counter
├── nodes/ # One file per node + index.ts registry (+ NodeTypeId)
└── utils/
├── nodeRunners.ts # Registry: nodeType → NodeRunner
├── runWorkflow.ts # Topological executor (Kahn's algorithm)
├── workflowIO.ts # downloadWorkflow / parseWorkflowFile / hydrateNodes
├── nodeIdCounter.ts # newId(prefix) + bumpCounterPast(ids[])
├── resultsStore.ts → store/
├── reconciliationService.ts # isReconciledValue (canonical), reconcileField, FIELD_AUTHORITY_MAP
├── filterTransformUtils.ts
├── exportUtils.ts # flattenRecord, toCSV/JSON/GeoJSON
├── fileReaders.ts # FileRecord + PDF/XML/text/image extraction
└── run<Name>Node.ts # One runner per runnable node type
Sidebar groups follow TaDiRAH 2.0 terminology (cross-referenced in src/components/TADIRAHMapping.tsx).
Group labels are UI-only — node type strings (e.g. 'gbifSearch') are serialised into .nfcs.json
and must never be renamed.
| Key | Component | Notes |
|---|---|---|
quickStart |
QuickStartNode |
AI workflow planner — describe a research question in plain English, KCL (arc:nexus) proposes search nodes + comments + SourceProfile/TableOutput/MapOutput; "Instantiate workflow" places them all. Requires KCL API key. |
comment |
CommentNode |
No handles. NodeResizer. Factory sets style: {width:220,height:120}. |
param |
ParamNode |
Text or Integer value; connects to search handles. |
| Key | Component | CORS |
|---|---|---|
gbifSearch |
GBIFSearchNode |
Direct. https://api.gbif.org/v1/occurrence/search. Max 300/req. |
lldsSearch |
LLDSSearchNode |
/llds-proxy/rest/items?expand=metadata. No server search — filter client-side. 15s timeout → localStorage cache fallback. Thin config over BackboneSearchNode (useCache footer toggle). |
ariadneSearch |
ARIADNESearchNode |
Direct CORS fetch. Pan-European archaeology portal (40+ institutions, 23 countries). Filters: Resource type, Getty AAT subject, Native subject, Country, Data type, Period, Contributor (set Contributor = "Archaeology Data Service" for ADS records). |
hsdsSearch |
HSDSSearchNode |
Vite proxy, no Cloudflare. Heritage Science Data Service — UK heritage aggregator (Historic England, HES, Cadw). Same filter set as ARIADNESearch plus Country = England/Scotland/Wales/Northern Ireland. hsds.* namespace. |
bodleianSearch |
BodleianSearchNode |
/bodleian-proxy/*. Oxford Bodleian Digital Collections. Filters: date range, language, origins, completeness, musical notation. bodleian.manifest → feeds ImageView (IIIF mode). Fixture mode supported. Thin config over BackboneSearchNode (single-select sort, fq* filters). |
europeanaSearch |
EuropeanaSearchNode |
Pre-configured API key (overridable via Param → apiKey handle). Cursor pagination up to 1,000 records. Adds europeana.thumbnail, europeana.shownAt, europeana.rights. |
smgSearch |
SMGSearchNode |
/smg-proxy/*. Science Museum Group collection. smg.manifest (IIIF) → ImageView. Fixture mode supported. Thin config over BackboneSearchNode (searchType body row switches endpoint). |
vaSearch |
VASearchNode |
/vam-proxy/*. V&A collection (API v2). Filters: images only, object type, year made from/to. vam.manifest, vam.iiifImageBase, vam.thumbnail. Thin config over BackboneSearchNode. |
adsSearchAdvanced |
ADSSearchAdvancedNode |
DEPRECATED — blocked by Cloudflare. Use ARIADNESearch (Contributor = "Archaeology Data Service") or HSDSSearch instead. |
adsLibrarySearch |
ADSLibraryNode |
DEPRECATED — blocked by Cloudflare, same as above. |
mdsSearch |
MDSSearchNode |
/mds-proxy. Two-step HTML scraper. Capped at 200 (amber status text). Thin config over BackboneSearchNode. |
| Key | Component | Notes |
|---|---|---|
localFolderSource |
LocalFolderSourceNode |
File System Access API — no runner (user gesture required). dirHandle in useRef, lost on refresh. 5 typed output handles: results (all), pdf, xml, text, image; partitioned store keys ${id}:pdf etc. |
localFileSource |
LocalFileSourceNode |
No runner. fileMode: 'csv' | 'xml' | 'image'. CSV → column-keyed rows; xml/image → single FileRecord via extractFileContent. |
sampleDataSource |
SampleDataSourceNode |
Has a runner — participates in Run All (unlike LocalFolderSource/LocalFileSource). Loads pre-packaged collection files from public/fixtures/ via a curated manifest (public/fixtures/collections-manifest.json). Pick a named package, tick individual files; fetches + extracts as FileRecord[]. Same 5 typed output handles as LocalFolderSource. Ideal for offline demos and saved example workflows. |
urlFetch |
URLFetchNode |
#0c4a6e. Adds fetchedContent, fetchedHtml (cleaned body), fetchStatus, fetchedAt. AbortController cancel. URL field picker scans namespace sub-objects; runner resolves dot-notation field paths (e.g. adsLibrary.downloadUrl). |
frameSenseSource |
FrameSenseSourceNode |
Reads a folder pre-processed by the FrameSense CLI; one record per detected shot (framesense.* namespace + imageDataUrl). No runner — pick folder manually, skipped by Run All. |
| Key | Component | Header |
|---|---|---|
kclNode |
KCLNode |
#881337. Per-record KCL inference. Vision-capable. Model-dependent truncation (getContentMaxChars). |
kclField |
KCLFieldNode |
#881337. Single-field KCL inference. Per-record (live token preview, stream:true, partial results) or aggregate (per-value truncation). Prompt recipe bar. |
geocoding |
GeocodingNode |
#065f46. Getty TGN + Wikidata place enrichment. Scores candidates: Dice similarity × 0.5 + tier weight × 0.3 + corroboration × 0.2; auto-resolves above threshold, else inline review panel. Candidate lists cached 30 days in localStorage. Adds decimalLatitude/decimalLongitude + geocoding.*. |
smartGeocoder |
SmartGeocoderNode |
#1e3a5f. LLM-assisted place extraction (KCL arc:lite) from prose fields, then resolved via the same Getty TGN → Wikidata gazetteer as Geocoding. Adds smartGeo.*. Requires KCL API key. |
reconciliation |
ReconciliationNode |
#7c3aed. Uses /reconcile-proxy/en/api. Scores normalised 0–1. |
wikidataEnrich |
WikidataEnrichNode |
#0369a1. Fetches Wikidata properties for QIDs (e.g. from *_reconciled fields). |
mergeByQID |
MergeByQIDNode |
#6b21a8. Merges records from multiple upstream sources by shared Wikidata QID into one record per entity. Toggle "Keep unmatched" to pass through unreconciled records unchanged. Extracts the QID from the first *_reconciled field found; unwraps array-valued _reconciled fields (a field can carry multiple candidate reconciliations) — see gotcha 23. |
ollamaNode |
OllamaNode |
#312e81. stream:true on /ollama/api/chat. Vision: strip data URL prefix, use images:[] field, blank {{content}}. tokenInput state from d.maxTokens directly (TDZ gotcha). |
ollamaField |
OllamaFieldNode |
#1e1b4b. Per-record or aggregate mode. Templates: {{value}}, {{field}}, {{count}}, {{values}}. Same TDZ gotcha as OllamaNode. |
htmlSection |
HTMLSectionNode |
#065f46. CSS selector on fetchedHtml → overwrites fetchedContent. Adds htmlSelector. |
xmlSection |
XMLSectionNode |
#44403c. XPath on content field (XML text) → writes xmlContent. Schema inspector + live preview. Strips default namespace before XPath eval. Adds xmlXPath. |
quickNote |
QuickNoteNode |
#0f766e. Display-only, pass-through output handle, no runner. Three modes (selector: Note · Structured · Score): Note — free-text per-record annotation written to _note, field picker to read any upstream field in full while annotating. Structured — configure {key,label} fields once; per-record form assembles a clean JSON gold-standard object into a target field (default _note) without hand-typing JSON — flows into Evaluator's {{__reference}} token. A structured entry clears any prose note for that record (mutual exclusion). Score — constrained per-criterion human scoring; configure {key,label,scale:number[]} criteria + target field (default human_score); writes an object keyed by criterion + flat human_c* keys + a labels map for downstream display. Human counterpart to Evaluator's LLM judge. Notes shared with TableOutput via notesStore. |
| Key | Component | Notes |
|---|---|---|
filterTransform |
FilterTransformNode |
#4f46e5. TransformOp is a discriminated union — always replace full op on type change. |
spatialFilter |
SpatialFilterNode |
#0891b2. Leaflet draw → bbox filter. |
smartFilter |
SmartFilterNode |
#0f4c81. Natural-language → filter records via KCL inference. Requires KCL API key. |
deduplicate |
DeduplicateNode |
#0f766e. Removes duplicate records by a chosen field value. QuickStart auto-inserts this before a shared SourceProfile when a plan has 2–3 nodes of the same search type. |
sourceProfile |
SourceProfileNode |
#1f2937. Schema inspection for one or more upstream sources: per-source completeness bars, field population rates, cross-source field correspondence map. Optional KCL-generated research narrative (arc:nano, 16k context). Pass-through output handle. |
fieldDistribution |
FieldDistributionNode |
#047857. Faceted bar chart over a chosen field; click bars to filter records. |
| Key | Component | Notes |
|---|---|---|
tableOutput |
TableOutputNode |
#0d9488. Pass-through output handle id="results" at top:13. Loop prevention via useRef fingerprint. Double-click expands. Toolbar: show all columns (default on) + expand namespaces (flattens one level of namespace objects into dot-notation cols). Page size selector, column sort, live text filter. NodeResizer (selected prop now destructured — see gotcha 24). |
mapOutput |
MapOutputNode |
Uses decimalLatitude/decimalLongitude. |
timelineOutput |
TimelineOutputNode |
ISO dates, bare years, BCE (-1199). |
timelineView |
TimelineViewNode |
#1e293b. Filter records by date range on an interactive timeline; distinct from TimelineOutput (which is display-only). |
htmlPreview |
HTMLPreviewNode |
#0c4a6e. Sandboxed iframe rendering fetchedHtml; click any element to capture its CSS selector and pass it back to HTMLSection/HTMLExtract. |
quickView |
QuickViewNode |
#1e293b. No runner. Field inspector: paginates CSV/TSV (50 rows/page), truncates plain text at 50k chars. Redirects image data URLs to ImageViewNode. |
imageView |
ImageViewNode |
#1c3144. No runner. NodeResizer. Two modes: Images (field picker for upstream records OR imageDirectUrl for a public URL — direct URL overrides field, suppresses record nav) and IIIF (v2/v3 manifest, zoom-tiered IIIF Image API requests, info.json for dimensions). Info panel: IIIF manifest metadata + image info; Images: EXIF parsed inline from first 64 KB of JPEG data URLs. |
| Key | Component | Notes |
|---|---|---|
comparisonReport |
ComparisonReportNode |
#3730a3. Read-only evaluation surface; no runner, skipped by Run All. Map five report roles to upstream fields via dropdowns: original, note/reference, response/candidate, judge score, human score. Renders per-record cards + an aggregate agreement summary matrix. Human criterion labels (from QuickNote Score mode's labels map) appear in the summary. Double-click for a projector-legible full-screen view. Terminal node of the evaluation pipeline — see below. |
citation |
CitationNode |
#78350f. Paginated bibliography from _citation metadata stamped by source runners. Copy all / download .txt. |
export |
ExportNode |
#b45309. CSV/JSON/GeoJSON. flattenRecord expands *_reconciled to _qid/_label/_confidence/_status cols. |
jsonOutput |
JSONOutputNode |
Double-click expands. |
kclOutput |
KCLOutputNode |
#3b0764. Card display of kclResponse values with per-card copy buttons. |
ollamaOutput |
OllamaOutputNode |
#0f172a. Reads ollamaResponse. No pass-through. |
saveSearch |
SaveSearchNode |
Save records + metadata to .nfcs.json. |
loadSavedSearch |
LoadSavedSearchNode |
Replay a .nfcs.json saved search; shows provenance metadata and per-source record counts. |
Experimental (alpha — hidden in Simple mode, collapsed by default)
| Key | Component | Notes |
|---|---|---|
sparqlSearch |
SparqlSearchNode |
#4c1d95. Wikidata SPARQL search via /wdqs-proxy. Two modes: Builder (instance-of picker with subclasses toggle, property-filter rows over PROPERTY_GROUPS — Q-id value → exact triple, text → CONTAINS filter — output-column checkboxes, live read-only query preview; buildSparqlQuery in sparqlQueryBuilder.ts is the ONLY generator) and Raw SPARQL (escape hatch; hand edits set builderCustom, any builder change regenerates over them). Keyword row seeds a wikibase:mwapi EntitySearch in builder mode AND names fixtures; ✨ NL assist (KCL arc:lite, SmartFilter call pattern, KCL_API_KEY_NODES member) translates plain English → SPARQL, landing in Raw mode with an explanation for review before running; wirable query/limit handles at the shell contract offsets (51/78); a query without LIMIT gets the limit row appended. Bindings → sparql.* namespace; ?item/?itemLabel/?itemDescription → id/title/description; _qid written for WikidataEnrich/MergeByQID; WKT Point(lon lat) → map coordinates. Fixture: sparqlSearch-default.json (Turner paintings). |
evaluatorNode |
EvaluatorNode |
#3f3f46. LLM-as-judge, runnable. Scores a candidateField against a referenceField on the same record using an ARC model at temperature 0 for repeatability. Per-criterion scoring only (never one aggregate score) — built-in rubric presets (Extraction agreement, Interpretive agreement, Rubric-from-note). Template tokens: {{__reference}}, {{__candidate}}. Writes record.eval = {scores, reasons, raw, status} + flat eval_c* columns. Shows judge-vs-human agreement readout when a human score field is present. Tolerant JSON parsing — never throws; sets status: 'parse_error' on bad output. Requires KCL API key. |
Experimental nodes carry alpha: true in SIDEBAR_ITEMS. The group renders with an amber left-border
and ⚗ icon in the sidebar. When simpleMode is active the entire group is hidden (not just its items).
quickNote, evaluatorNode, and comparisonReport form a judge-vs-human evaluation
pipeline spanning three TaDiRAH groups:
source → KingsInference (generates candidate response)
→ QuickNote (Note/Structured mode — human writes reference)
→ QuickNote (Score mode — human rates criteria)
→ Evaluator (LLM judge scores candidate vs reference)
→ ComparisonReport (side-by-side cards + agreement matrix)
QuickNote nodes are display-only and pass records through unchanged, so they can be
placed anywhere in the chain. notesStore (src/store/notesStore.ts) keys notes by
${nodeId}::${recordId} — scoped to the authoring node, not global — so parallel
branches stay isolated and downstream nodes inherit a note only via the _note
field the authoring node injects.
Experimental nodes carry alpha: true in SIDEBAR_ITEMS. The group renders with an amber left-border
and ⚗ icon in the sidebar. When simpleMode is active the entire group is hidden (not just its items).
NodeTypeId (exported from src/nodes/index.ts, derived from nodeTypes) links the registries: nodeRunners
and NODE_DEFAULTS carry satisfies Partial<Record<NodeTypeId, …>> guards and SidebarItem.type is NodeTypeId,
so a typo'd or unregistered type string in steps 2/5/6 is a compile error instead of a silently missing facet.
Register the component (step 4) FIRST — the other registries type-check against it.
src/utils/run<Name>Node.ts— implementNodeRunner- Add to
src/utils/nodeRunners.ts src/nodes/<Name>Node.tsx- Add to
src/nodes/index.ts - Factory in
NODE_DEFAULTS—src/config/nodeDefaults.ts(not App.tsx) - Sidebar entry in
SIDEBAR_ITEMS—src/config/sidebarItems.ts(not App.tsx) - Data interface + union in
AppNode—src/types/AppNode.ts(not App.tsx) - Proxy rule in
server/proxies.mjs(PROXY_TABLE) if needed — applies to both dev and prod automatically
Exceptions: localFolderSource (user gesture) skips 1–2. quickView, imageView, comment (display-only) skip 1–2 and have no handles.
Never put record arrays in updateNodeData — causes O(n) re-renders across all useNodes() subscribers.
Use src/store/resultsStore.ts:
setNodeResults(nodeId, records)→ returns version intgetNodeResults(nodeId)→ recordsclearNodeResults(nodeId)
Runner pattern: clearNodeResults first → do work → setNodeResults → updateNodeData({ …, resultsVersion: version }). The version integer is the reactivity signal; useUpstreamRecords reads it and fetches from the store.
type NodeRunner = (
nodeId: string,
getNodes: () => Node[],
edges: Edge[],
updateNodeData: (id: string, data: Record<string, unknown>) => void,
) => Promise<void>- Never throw — always leave node in
'success'|'cached'|'error'status. - Batch runners (
ollamaNode,ollamaField,urlFetch) wrap each record intry/catch; callsetNodeResultsafter each record for partial results.
- All adapters →
UnifiedRecord[]. Service fields namespaced:record.gbif.*,record.ads.*, etc. useUpstreamRecords(nodeId)merges all edges wheretargetHandle === 'data'.TableOutputNodepass-through usestargetHandle === 'results'.- Reconciled fields:
${field}_reconciledcontainingReconciliationResult | null.
renderCellinReconciledCell.tsx— sole cell renderer. Do not write localfmt()in output nodes.isReconciledValueinreconciliationService.ts— sole location. Do not redefine.allFlatColumnsinTableOutputNode— must includeisReconciledValue(v)check or*_reconciledcolumns vanish.newId(prefix)/bumpCounterPast(ids[])innodeIdCounter.ts— callbumpCounterPastafter workflow load.TRANSIENT_FIELDSinworkflowIO.tsstripsresults,status, counts,resultsVersion,_capped,_total,folderName,pdfCount/xmlCount/textCount/imageCountbefore save.makeSearchRunner(config)insearchRunnerFactory.ts— builds a NodeRunner for Elasticsearch-style services (?q&size&page+{total:{value},hits}); ARIADNE and HSDS runners are ~30-line configs over it. Shared pieces (resolveParamEdge,resolveLimit,finishRunnerSuccess/Error) live inrunnerHelpers.tsand are used by ALL search runners where semantics match exactly; deliberate exceptions — Europeana's limit clamp (itsMath.max(1,…)maps negatives → 1, not the 20 fallback), the MDS/Europeana success blocks (⚠ cappedmessage +_capped/_totalkeys), LLDS's cached/error-fallback terminals. Europeana (cursor pagination,_capped), Bodleian (dual terminal paths), SMG, VA, GBIF, LLDS, MDS keep bespoke PAGINATION — do NOT force-fit non-page/size services into the factory.BackboneSearchNodeconfig (src/nodes/BackboneSearchNode.tsx) — the shared search-node COMPONENT shell (distinct from the runner factory above; a node can use the shell with a bespoke runner).BackboneSearchConfigis declarative: optionalsort(dual-select by default,singleSelectdrops the order arrow), optionalfetchAll(bool or{label}),filters: FilterSpec[](select/text/checkbox/range— range writes${key}From/${key}To),extraBodyRow,footer(caption /_cappedbadge / extra toggle),statusColours,queryDataKey(defaultinlineQuery). Handle contract is frozen:querytop=51,limittop=78, outputresults— pinned bybackboneHandles.test.ts, which must also assert every newly migrated config's serialised filter keys. Europeana (extra apiKey handle row shifts tops to 93/120) and GBIF (five wirable rows) deliberately stay OFF the shell — their handle layouts differ and the serialised-edge contract must not move.renderTemplate/renderFieldTemplateAggregate/renderFieldTemplatePerRecordinpromptTemplates.ts— the ONLY implementations of{{token}}prompt substitution. Do not re-inline them in runners or components. The{{_lineage}}token is live in KCLNode/OllamaNode/Evaluator (runner AND component paths — six sites): when the user template contains it, the caller deriveslineageToNarrative(collectLineage(…))ONCE before the record loop and spreads_lineageinto the substitution record; templates without the token are byte-identical to before. KCLFieldNode/OllamaFieldNode are not wired (their field-mode token sets are separate — follow-up).fetchWithTimeout(url, init?, timeoutMs?)infetchWithTimeout.ts— AbortController + 30s default. Use for any new network call in runners/clients; a fetch without a timeout can hang a Run All wave indefinitely.normaliseRecord/normaliseRecordsinrecordNormalise.ts— applied at the two legacy-record entry points (fixtureUtilsfixture loads,LoadSavedSearchNode). Moves stale flat GBIF fields intogbif.*and drops Bodleian's old_service/thumbnailstrays. Idempotent. Old fixtures and saved.nfcs.jsonfiles keep working without rewriting.collectUpstreamRecords(nodeId, edges)inupstreamRecords.ts— shared utility used by all process runners. TYPED_HANDLES (pdf,xml,text,image) use partitioned store keys${sourceId}:${handle}; all others use plainsourceId.useUpstreamRecords(nodeId)hook — same TYPED_HANDLES logic for reactivity; uses${type}Countkey from node data.collectLineage(nodeId, nodes, edges)inlineage.ts— derive-on-demand pipeline history (docs/context-accrual.md). Walks the upstream subgraph overdata/resultstarget-handle edges (param handles are config, not data flow), appliesresolveProxyEdgesfor collapsed groups, returns a topologically orderedLineageGraphwithstripTransient'd params + counts read from raw data. Pure read — call from runners withgetNodes()/edges, from components withuseNodes()/useEdges()values. Thestaleflag is heuristic: an upstream node that never ran this session, or claims aresultsVersionits store no longer holds.lineageToNarrative(graph, {maxChars})(same file, default 2000 chars) renders it LLM-ready: linear chains as numbered lists, parallel branches as lettered sections with shared ancestors described once, joins under "Then:"; the budget drops earliest steps whole (never mid-sentence) and a stale graph gets an explicit warning prefix.describeNode(node)/lineageDescribersinlineageDescribers.ts— per-node-type one-sentence operation summaries for the lineage narrative;satisfies Partial<Record<NodeTypeId, …>>guard, generic label+counts fallback. Count keys are deliberately NOT uniform across runners (reconciliationresolvedCount/reviewCount, geocoding bareresolved/pending/failed, mergemergedCount/unmatchedCount, searchcount) — the describers pin the real names andlineageDescribers.test.tsfails if a runner renames one.
RECONCILE_API = '/reconcile-proxy/en/api'— never use direct URL.TransformOpdiscriminated union — replace entire op on type change; never patch.TableOutputNodefingerprintuseRef— prevents infinite loop; do not remove.allFlatColumnsneedsisReconciledValuecheck — else reconciled columns excluded.- MDS capped at 200, ADS server hard-caps at 50 — both by design.
LocalFolderSourceNodedirHandleinuseRef— lost on page refresh.- OllamaNode vision: never put base64 data URL in
{{content}}; useimages:[]and blank the content substitution. - pdfjs worker must stay on CDN (
unpkg.com/pdfjs-dist@{version}/build/pdf.worker.min.mjs) — local import breaks Vite. - Ollama
tokenInputstate — initialise fromd.maxTokensdirectly; TDZ crash if referencing a laterconst. - Ollama runners use
stream:true—stream:falsegenerates exactlynum_predicttokens regardless of natural stop. - Puppeteer singleton:
_browserPromise = nullindisconnectedhandler — do not remove. bumpCounterPast(loadedIds)after workflow load — prevents ID collisions.CommentNodesize viastyle: {width, height}on node object, notdata.fetchedHtmlis cleaned body HTML —HTMLSectionNodereads this, not raw response.XMLSectionNodestrips default XML namespace (xmlns="...") beforeDOMParser/document.evaluate— required for XPath to work on namespaced documents.LocalFolderSourceNodehandle positions (top: 70/94/118/142/166) are fixed — the Outputs section must remain FIRST in the body with consistent heights, or handles misalign.LocalFolderSourceNodetyped store partitions: clear all 5 keys (id,id:pdf,id:xml,id:text,id:image) on re-scan.upstreamRecords.tsTYPED_HANDLES = Set(['pdf','xml','text','image'])—resultsanddataare NOT in this set, so they fall through to plainsourceIdlookup.- KCL node truncation via
getContentMaxChars(model)fromkclConfig.ts— not a hard-coded constant. Per-value truncation in aggregate mode happens BEFORE joining values. - KCLFieldNode streaming returns accumulated full text (not empty string) from
kclChat. In componenthandleRun,liveTokensstate must NOT be in theuseCallbackdeps array for non-streaming to work (stale closure is expected in that narrow context). Runner path stays non-streaming; partial results via per-recordsetNodeResults. - KCLFieldNode per-record mode: do NOT set
resultsVersion: 0in the finalupdateNodeDataafter the loop — partial updates already track version correctly. Setting it to 0 resets reactivity and invisible results to downstream nodes. - CommentNode easter egg: click title 5 times within 1.5 seconds to unlock input/output handles for illustrating data-flow gaps.
runMergeByQIDNode.tsextractQIDInfomust handle array-valued*_reconciledfields, not just a singleReconciliationResult | null— a field can carry multiple candidate reconciliations. Always normalise withArray.isArray(raw) ? raw : [raw]before scanning for a resolved/review QID.TableOutputNodecolumn-resize drag handler: never re-readresizingRef.currentmore than once permousemoveafter the initial null-check — cache it to a local (const r = resizingRef.current; if (!r) return) before use. Re-reading it later in the same handler risks a null dereference if the ref is cleared mid-drag (e.g.mouseupracingmousemove).- GBIF domain fields (
scientificName,kingdom, …,datasetName,eventDate,basisOfRecord,institutionCode) exist ONLY underrecord.gbif.*— the adapter stores the raw occurrence wholesale and writes no flat copies. Readers use dot-notation (gbif.scientificName);authoritiesForFieldmatches namespaced fields by last segment so typed authorities still apply.periodStart/End/Nameare intentionally top-level — they are cross-service temporal fields written by three adapters, NOT domain-specific.