You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tika.apache.org by ta...@apache.org on 2026/09/11 18:04:07 UTC

(tika) branch main updated: TIKA-4889: Inference bindings (#3153)

This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 3b988e2566 TIKA-4889: Inference bindings (#3153)
3b988e2566 is described below

commit 3b988e2566e025e283659478a59d9afc76881d9b
Author: Tim Allison <ta...@apache.org>
AuthorDate: Fri Sep 11 14:04:00 2026 -0400

    TIKA-4889: Inference bindings (#3153)
---
 CHANGES.txt                                        |   8 +
 docs/modules/ROOT/nav.adoc                         |   1 +
 .../ROOT/pages/configuration/inference.adoc        | 111 +++++++
 .../tika/cli/TikaCLIInferenceBindingsTest.java     | 269 +++++++++++++++
 .../tika/extractor/EmbeddedMetadataLookup.java     |  43 +++
 .../org/apache/tika/parser/AutoDetectParser.java   |  62 +++-
 .../apache/tika/parser/RecursiveParserWrapper.java |   5 +
 .../org/apache/tika/parser/hook/ParseHook.java     |  60 ++++
 .../org/apache/tika/parser/hook/ParseHooks.java    | 123 +++++++
 .../tika/parser/inference/InferenceBinding.java    | 101 ++++++
 .../tika/parser/inference/InferenceDispatcher.java | 283 ++++++++++++++++
 .../tika/parser/inference/InferenceSelection.java  |  54 +++
 .../tika/parser/inference/InferenceTask.java       |  39 +++
 .../tika/parser/inference/InferenceUnit.java       | 108 ++++++
 .../apache/tika/parser/inference/InputKind.java    |  40 +++
 .../sax/AbstractRecursiveParserWrapperHandler.java |  11 +
 .../tika/sax/RecursiveParserWrapperHandler.java    |  10 +
 .../apache/tika/parser/hook/ParseHooksTest.java    | 293 +++++++++++++++++
 .../parser/inference/InferenceDispatcherTest.java  | 362 +++++++++++++++++++++
 .../org/apache/tika/parser/mock/MockEngine.java    |  25 ++
 .../java/org/apache/tika/parser/mock/MockTask.java |  48 +++
 .../org/apache/tika/inference/ChunkTarget.java     |   7 +-
 .../java/org/apache/tika/inference/EmbedTask.java  | 109 +++++++
 .../tika/inference/OpenAIImageEmbeddingParser.java |   3 +
 .../org/apache/tika/inference/EmbedTaskTest.java   | 181 +++++++++++
 .../apache/tika/pipes/core/PipesClientTest.java    |  26 ++
 .../configs/tika-config-inference-bindings.json    |  57 ++++
 .../apache/tika/config/loader/InferenceLoader.java | 195 +++++++++++
 .../apache/tika/config/loader/TikaJsonConfig.java  |   1 +
 .../org/apache/tika/config/loader/TikaLoader.java  |  10 +
 .../tika/config/loader/InferenceLoaderTest.java    | 148 +++++++++
 .../org/apache/tika/config/loader/TestTask.java    |  44 +++
 .../org/apache/tika/server/core/CXFTestBase.java   |   7 +
 .../server/standard/InferenceBindingsWireTest.java |  85 +++++
 .../configs/tika-config-inference-bindings.json    |   6 +
 35 files changed, 2927 insertions(+), 8 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 9fded46d6c..617757c94a 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
 Release 4.1.0 - unreleased
 
+   * New "inference" config section: bind a named engine to an input (IMAGES)
+     and tasks (embed), and every image document Tika parses is embedded in
+     one request per document tree; vectors land on the document the image
+     belongs to, as it appears in the output. Per request, "inference" in
+     parse-context selects bindings or switches inference off.
+     openai-image-embedding-parser keeps working, one request per image
+     (TIKA-4889).
+
    * New "engines" config section: a map of names to inference engines, each
      an endpoint or local binary with its settings (openai-embedding-engine
      posts up to maxBatchSize images per request to an OpenAI-compatible
diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index 5018228fd1..aa5ec5047f 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -60,6 +60,7 @@
 ** xref:configuration/parsers/vlm-parsers.adoc[VLM Parsers (Claude, Gemini, OpenAI)]
 ** xref:configuration/parsers/external-parser.adoc[External Parser (ffmpeg, exiftool, etc.)]
 ** xref:configuration/parsers/tess4j-parser.adoc[Tess4J OCR (In-Process, advanced)]
+** xref:configuration/inference.adoc[Inference: Engines and Bindings]
 ** xref:configuration/metadata-filters.adoc[Metadata Filters]
 ** xref:configuration/detectors.adoc[Detectors]
 ** xref:configuration/digesters.adoc[Digesters]
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc b/docs/modules/ROOT/pages/configuration/inference.adoc
new file mode 100644
index 0000000000..1757489bb0
--- /dev/null
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -0,0 +1,111 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+= Inference: engines and bindings
+:toc:
+
+Inference is model interpretation of a document's bytes that does not produce its text:
+embeddings, captions, tags, classifications. (Engines that produce text, OCR and transcription,
+are xref:configuration/index.adoc#text-recognizers[text recognizers].) Three sections
+configure it, each answering one question.
+
+== `engines`: what can be called
+
+A map of names you choose to one engine each: an endpoint or a local binary with its settings.
+An engine knows nothing about documents. Two names may point at the same endpoint with
+different models. Secrets and paths live here and only here.
+
+[source,json]
+----
+{
+  "engines": {
+    "clip": { "openai-embedding-engine": { "baseUrl": "http://clip:8000", "model": "siglip2",
+                                           "maxBatchSize": 32 } }
+  }
+}
+----
+
+`openai-embedding-engine` posts to `/v1/embeddings` in the OpenAI shape and sends up to
+`maxBatchSize` images per request. Options: `baseUrl`, `model`, `apiKey`, `timeoutMillis`
+(120000), `maxBatchSize` (32), `embeddingsPath`, `apiKeyHeaderName`, `apiKeyPrefix`.
+
+== `inference`: what runs on what
+
+A list of bindings. Each names an engine, says what it is fed, and what to ask:
+
+[source,json]
+----
+{
+  "inference": [
+    { "id": "picture-vectors", "engine": "clip", "input": "IMAGES", "tasks": ["embed"],
+      "_mime-include": ["image/png", "image/jpeg"], "maxChunks": 100 }
+  ]
+}
+----
+
+`id`:: Names the binding; defaults to `<engine>-<input>`. Unique within the list.
+`input`:: What the binding is fed. `IMAGES` is every image document Tika parses: an inline
+  picture, an image attachment, a top-level image file. (`PAGES`, `TEXT` and `MEDIA` follow.)
+`tasks`:: One call per task on the same engine and input. `embed` writes one vector chunk per
+  unit into `tk:chunks`, with the page locator when the unit has a page.
+`maxChunks`:: How many units one document tree may send; -1 for no limit.
+`maxBytes`:: Largest unit the binding accepts (20 MB unless set; -1 for no limit); larger
+  ones are skipped and counted.
+`enabled`:: `false` keeps the binding configured but idle until a request selects it.
+`_mime-include`, `_mime-exclude`:: Narrow by media type within the input kind. With neither,
+  an `IMAGES` binding skips vector and CAD image types (SVG, EMF, DWG, ...) that no embedding
+  endpoint takes.
+
+A binding that names an engine not in `engines`, a task that cannot use its engine (`embed`
+on a chat engine), or an unknown key fails config load.
+
+== How it runs
+
+Parsers do not call engines. Every image document Tika parses is offered to one dispatcher
+after its own parser has run, whatever container it came from and however deep. The
+dispatcher keeps the units of the whole document tree until the top-level parse ends, then
+sends each binding's units to its engine in `maxBatchSize` requests: a docx with twelve
+pictures is one embeddings request, not twelve; if one image in a batch is rejected, the rest
+are retried one by one. Results land on the document a unit belongs to, as it appears in the
+output: a picture in the body of a docx or an email puts its vector on the docx or the email,
+with an `embedded` locator naming the picture, whether the docx is the file itself or inside
+a zip; an attachment keeps its own. A failed request marks
+the top-level document with a warning and never fails the parse.
+
+The units are held as files the dispatcher owns, bounded by each binding's `maxChunks` and
+`maxBytes`, and deleted when the flush ends. Vectors are visible in the recursive outputs
+(`/rmeta`, `-J`, pipes); a streaming `/tika` parse keeps only what lands on the top-level
+document.
+
+== Per request
+
+[source,json]
+----
+{ "parse-context": { "inference": { "bindings": ["picture-vectors"], "enabled": true } } }
+----
+
+`enabled: false` runs nothing for this request; `bindings` names the ones that run, every
+enabled binding when absent. Names only, so it is wire-safe and works as a
+xref:using-tika/server/index.adoc[preset]. A name not in the configured list fails the request.
+Engines cannot be defined or changed per request.
+
+== From `openai-image-embedding-parser`
+
+The 4.0 `openai-image-embedding-parser` still works as a text-recognizers entry and embeds one
+image per request. The same endpoint as an engine plus a binding on `IMAGES` embeds a
+document's images in one request and puts the vectors where they belong; move the `baseUrl`,
+`model` and `apiKey` to the engine and drop the parser entry.
diff --git a/tika-app/src/test/java/org/apache/tika/cli/TikaCLIInferenceBindingsTest.java b/tika-app/src/test/java/org/apache/tika/cli/TikaCLIInferenceBindingsTest.java
new file mode 100644
index 0000000000..bd30d64fe5
--- /dev/null
+++ b/tika-app/src/test/java/org/apache/tika/cli/TikaCLIInferenceBindingsTest.java
@@ -0,0 +1,269 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.cli;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import javax.imageio.ImageIO;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.http.TikaTestHttpServer;
+import org.apache.tika.inference.Chunk;
+import org.apache.tika.inference.ChunkSerializer;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+/**
+ * The consumer's path for the bindings shape: an engine named once, one inference binding on
+ * IMAGES, and -J shows the two pictures of a docx embedded in one request, their vectors on
+ * the docx itself, each naming the picture it came from.
+ */
+public class TikaCLIInferenceBindingsTest {
+
+    private static final String EMBEDDING_RESPONSE = "{\"data\":["
+            + "{\"index\":1,\"embedding\":[1.0,0.2,0.3]},"
+            + "{\"index\":0,\"embedding\":[0.0,0.2,0.3]}]}";
+
+    @TempDir
+    Path tmp;
+
+    @Test
+    public void testOneRequestPerDocumentVectorsOnTheDocx() throws Exception {
+        Path docx = tmp.resolve("two-pictures.docx");
+        writeDocx(docx);
+        try (TikaTestHttpServer server = new TikaTestHttpServer()) {
+            server.enqueue(new TikaTestHttpServer.MockResponse(200, EMBEDDING_RESPONSE));
+            Path config = tmp.resolve("tika-config.json");
+            Files.writeString(config, """
+                    {
+                      "engines": {
+                        "clip": { "openai-embedding-engine": { "baseUrl": "BASE_URL", "model": "clip" } }
+                      },
+                      "inference": [
+                        { "id": "picture-vectors", "engine": "clip", "input": "IMAGES",
+                          "tasks": ["embed"], "maxChunks": 10 }
+                      ]
+                    }
+                    """.replace("BASE_URL", server.url()));
+
+            JsonNode output = new ObjectMapper().readTree(
+                    run("--config=" + config, "-J", docx.toUri().toString()));
+
+            assertEquals(1, server.getRequestCount(), "both pictures in one request");
+            JsonNode request = new ObjectMapper().readTree(server.takeRequest().body());
+            assertEquals(2, request.get("input").size());
+
+            JsonNode root = null;
+            List<JsonNode> pictures = new ArrayList<>();
+            for (JsonNode m : output) {
+                if (!m.has("tk:embedded-id-path")) {
+                    root = m;
+                } else if ("INLINE".equals(text(m, "tk:embedded-resource-type"))) {
+                    pictures.add(m);
+                }
+            }
+            assertNotNull(root);
+            assertEquals(2, pictures.size());
+            Set<String> picturePaths = new HashSet<>();
+            for (JsonNode picture : pictures) {
+                assertFalse(picture.has("tk:chunks"), "chunks land on the docx, not the picture");
+                picturePaths.add(text(picture, "tk:embedded-id-path"));
+            }
+            List<Chunk> chunks = ChunkSerializer.fromJson(text(root, "tk:chunks"));
+            assertEquals(2, chunks.size());
+            Set<String> origins = new HashSet<>();
+            for (Chunk chunk : chunks) {
+                assertEquals(3, chunk.getVector().length);
+                origins.add(chunk.getLocators().getEmbedded().get(0).getIdPath());
+            }
+            assertEquals(picturePaths, origins);
+        }
+    }
+
+    /** A docx inside a zip: the vectors land on the docx as it appears in the output list. */
+    @Test
+    public void testNestedContainerAndAttachment() throws Exception {
+        Path zip = tmp.resolve("bundle.zip");
+        try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(zip))) {
+            entry(out, "two-pictures.docx", docxBytes());
+            entry(out, "solo.png", png(3));
+        }
+        try (TikaTestHttpServer server = new TikaTestHttpServer()) {
+            server.enqueue(new TikaTestHttpServer.MockResponse(200, "{\"data\":["
+                    + "{\"index\":0,\"embedding\":[0.0,0.2,0.3]},"
+                    + "{\"index\":1,\"embedding\":[1.0,0.2,0.3]},"
+                    + "{\"index\":2,\"embedding\":[2.0,0.2,0.3]}]}"));
+            Path config = tmp.resolve("tika-config.json");
+            Files.writeString(config, """
+                    {
+                      "engines": {
+                        "clip": { "openai-embedding-engine": { "baseUrl": "BASE_URL", "model": "clip" } }
+                      },
+                      "inference": [ { "engine": "clip", "input": "IMAGES", "tasks": ["embed"] } ]
+                    }
+                    """.replace("BASE_URL", server.url()));
+            JsonNode output = new ObjectMapper().readTree(
+                    run("--config=" + config, "-J", zip.toUri().toString()));
+            assertEquals(1, server.getRequestCount(), "all three images in one request");
+
+            JsonNode docx = null;
+            JsonNode solo = null;
+            int pictures = 0;
+            for (JsonNode m : output) {
+                String nameKey = TikaCoreProperties.RESOURCE_NAME_KEY.getName();
+                String name = m.has(nameKey) ? text(m, nameKey) : "";
+                if (name.endsWith("two-pictures.docx")) {
+                    docx = m;
+                } else if (name.endsWith("solo.png")) {
+                    solo = m;
+                } else if (m.has("tk:embedded-resource-type")
+                        && "INLINE".equals(text(m, "tk:embedded-resource-type"))) {
+                    pictures++;
+                    assertFalse(m.has("tk:chunks"));
+                }
+            }
+            assertNotNull(docx, "the docx is in the output list");
+            assertNotNull(solo, "the attached png is in the output list");
+            assertEquals(2, pictures);
+            assertEquals(2, ChunkSerializer.fromJson(text(docx, "tk:chunks")).size(),
+                    "the pictures' vectors land on the docx the wrapper kept, not the zip");
+            assertEquals(1, ChunkSerializer.fromJson(text(solo, "tk:chunks")).size(),
+                    "an attachment keeps its own vector");
+            assertFalse(output.get(0).has("tk:chunks"), "nothing lands on the zip");
+        }
+    }
+
+    @Test
+    public void testOffForOneRequest() throws Exception {
+        Path docx = tmp.resolve("two-pictures.docx");
+        writeDocx(docx);
+        try (TikaTestHttpServer server = new TikaTestHttpServer()) {
+            Path config = tmp.resolve("tika-config.json");
+            Files.writeString(config, """
+                    {
+                      "engines": {
+                        "clip": { "openai-embedding-engine": { "baseUrl": "BASE_URL", "model": "clip" } }
+                      },
+                      "inference": [ { "engine": "clip", "input": "IMAGES", "tasks": ["embed"] } ],
+                      "parse-context": { "inference": { "enabled": false } }
+                    }
+                    """.replace("BASE_URL", server.url()));
+            JsonNode output = new ObjectMapper().readTree(
+                    run("--config=" + config, "-J", docx.toUri().toString()));
+            assertEquals(0, server.getRequestCount(), "switched off for this request");
+            assertFalse(output.get(0).has("tk:chunks"));
+        }
+    }
+
+    private static void writeDocx(Path docx) throws Exception {
+        Files.write(docx, docxBytes());
+    }
+
+    /** A minimal docx: one paragraph and two pictures related to the main document part. */
+    private static byte[] docxBytes() throws Exception {
+        String rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
+            entry(zip, "[Content_Types].xml", """
+                    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+                    <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+                    <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+                    <Default Extension="xml" ContentType="application/xml"/>
+                    <Default Extension="png" ContentType="image/png"/>
+                    <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
+                    </Types>
+                    """.getBytes(UTF_8));
+            entry(zip, "_rels/.rels", ("""
+                    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+                    <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+                    <Relationship Id="rId1" Type="RELofficeDocument" Target="word/document.xml"/>
+                    </Relationships>
+                    """).replace("REL", rel).getBytes(UTF_8));
+            entry(zip, "word/document.xml", """
+                    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+                    <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+                    <w:body><w:p><w:r><w:t>two pictures</w:t></w:r></w:p></w:body></w:document>
+                    """.getBytes(UTF_8));
+            entry(zip, "word/_rels/document.xml.rels", ("""
+                    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+                    <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+                    <Relationship Id="rId1" Type="RELimage" Target="media/image1.png"/>
+                    <Relationship Id="rId2" Type="RELimage" Target="media/image2.png"/>
+                    </Relationships>
+                    """).replace("REL", rel).getBytes(UTF_8));
+            entry(zip, "word/media/image1.png", png(1));
+            entry(zip, "word/media/image2.png", png(2));
+        }
+        return bytes.toByteArray();
+    }
+
+    private static void entry(ZipOutputStream zip, String name, byte[] bytes) throws Exception {
+        zip.putNextEntry(new ZipEntry(name));
+        zip.write(bytes);
+        zip.closeEntry();
+    }
+
+    private static byte[] png(int shade) throws Exception {
+        BufferedImage image = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
+        for (int x = 0; x < 20; x++) {
+            for (int y = 0; y < 20; y++) {
+                image.setRGB(x, y, shade * 0x3F3F3F);
+            }
+        }
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        ImageIO.write(image, "png", out);
+        return out.toByteArray();
+    }
+
+    private static String text(JsonNode node, String field) {
+        JsonNode value = node.get(field);
+        assertNotNull(value, field);
+        return value.isArray() ? value.get(0).asText() : value.asText();
+    }
+
+    private static String run(String... args) throws Exception {
+        PrintStream stdout = System.out;
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try {
+            System.setOut(new PrintStream(out, true, UTF_8.name()));
+            TikaCLI.main(args);
+        } finally {
+            System.setOut(stdout);
+        }
+        String json = out.toString(UTF_8);
+        assertTrue(json.startsWith("["), json);
+        return json;
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/extractor/EmbeddedMetadataLookup.java b/tika-core/src/main/java/org/apache/tika/extractor/EmbeddedMetadataLookup.java
new file mode 100644
index 0000000000..3c9dfefc00
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/extractor/EmbeddedMetadataLookup.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.extractor;
+
+import org.apache.tika.config.TransientParseState;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.sax.AbstractRecursiveParserWrapperHandler;
+
+/**
+ * Finds the metadata kept for an embedded document after it has ended. The recursive parser
+ * wrapper sets it for the duration of the top-level parse; a late writer (inference at the
+ * end of the document) resolves its targets through it, because the wrapper keeps a copy of
+ * each embedded document's metadata and the parser's own object is no longer read.
+ *
+ * @since Apache Tika 4.1
+ */
+public class EmbeddedMetadataLookup implements TransientParseState {
+
+    private final AbstractRecursiveParserWrapperHandler handler;
+
+    public EmbeddedMetadataLookup(AbstractRecursiveParserWrapperHandler handler) {
+        this.handler = handler;
+    }
+
+    /** The kept metadata for the id path, or null when none is kept. */
+    public Metadata kept(String idPath) {
+        return idPath == null ? null : handler.getEmbeddedMetadata(idPath);
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/parser/AutoDetectParser.java b/tika-core/src/main/java/org/apache/tika/parser/AutoDetectParser.java
index 826a1dc362..9fe2a5fcb1 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/AutoDetectParser.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/AutoDetectParser.java
@@ -17,6 +17,7 @@
 package org.apache.tika.parser;
 
 import java.io.IOException;
+import java.nio.file.Path;
 
 import org.xml.sax.ContentHandler;
 import org.xml.sax.SAXException;
@@ -31,6 +32,7 @@ import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.mime.MediaTypeRegistry;
+import org.apache.tika.parser.hook.ParseHooks;
 import org.apache.tika.sax.SecureContentHandler;
 
 public class AutoDetectParser extends CompositeParser {
@@ -141,6 +143,8 @@ public class AutoDetectParser extends CompositeParser {
         return this.autoDetectParserConfig;
     }
 
+    private ParseHooks parseHooks;
+
     public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                       ParseContext context) throws IOException, SAXException, TikaException {
         // Compute digests before type detection if configured
@@ -170,21 +174,65 @@ public class AutoDetectParser extends CompositeParser {
             }
         }
         handler = decorateHandler(handler, metadata, context, autoDetectParserConfig);
-        // TIKA-216: Zip bomb prevention
+
         SecureContentHandler sch = handler != null ?
                 createSecureContentHandler(handler, tis, context) : null;
 
         initializeEmbeddedParserAndDetector(context);
+        // every document, top-level or embedded, enters here once: the hooks' seam
+        boolean seeded = parseHooks != null && context.get(ParseHooks.class) == null;
+        if (seeded) {
+            context.set(ParseHooks.class, parseHooks);
+        }
+        ParseHooks hooks = context.get(ParseHooks.class);
+        boolean topLevel = hooks != null && context.get(ParseHooks.Run.class) == null;
+        if (topLevel) {
+            context.set(ParseHooks.Run.class, new ParseHooks.Run());
+        }
+        ParseHooks.Run run = hooks == null ? null : context.get(ParseHooks.Run.class);
+        Metadata parent = run == null ? null : run.enter(metadata);
+        boolean failed = true;
         try {
-            // Parse the document
-            super.parse(tis, sch, metadata, context);
-        } catch (SAXException e) {
-            // Convert zip bomb exceptions to TikaExceptions
-            sch.throwIfCauseOf(e);
-            throw e;
+            if (topLevel) {
+                hooks.start(metadata, context);
+            }
+            Path pinned = hooks == null ? null : hooks.pin(type, metadata, tis, context);
+            try {
+                super.parse(tis, sch, metadata, context);
+            } catch (SAXException e) {
+                sch.throwIfCauseOf(e);
+                throw e;
+            }
+            failed = false;
+            if (pinned != null) {
+                hooks.offer(type, metadata, parent, pinned, context);
+            }
+        } finally {
+            if (run != null) {
+                run.exit(parent);
+            }
+            if (topLevel) {
+                try {
+                    hooks.end(metadata, failed, context);
+                } finally {
+                    context.set(ParseHooks.Run.class, null);
+                    if (seeded) {
+                        context.set(ParseHooks.class, null);
+                    }
+                }
+            }
         }
     }
 
+    /** Hooks set at config load; seeded into each parse's context. */
+    public void setParseHooks(ParseHooks parseHooks) {
+        this.parseHooks = parseHooks;
+    }
+
+    public ParseHooks getParseHooks() {
+        return parseHooks;
+    }
+
     private ContentHandler decorateHandler(ContentHandler handler, Metadata metadata,
                                            ParseContext context,
                                            AutoDetectParserConfig autoDetectParserConfig) {
diff --git a/tika-core/src/main/java/org/apache/tika/parser/RecursiveParserWrapper.java b/tika-core/src/main/java/org/apache/tika/parser/RecursiveParserWrapper.java
index 79470dce11..c905bfba22 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/RecursiveParserWrapper.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/RecursiveParserWrapper.java
@@ -30,6 +30,7 @@ import org.apache.tika.exception.TikaException;
 import org.apache.tika.exception.WriteLimitReachedException;
 import org.apache.tika.exception.ZeroByteFileException;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.extractor.EmbeddedMetadataLookup;
 import org.apache.tika.extractor.ParentContentHandler;
 import org.apache.tika.extractor.ParentMetadata;
 import org.apache.tika.io.FilenameUtils;
@@ -143,6 +144,9 @@ public class RecursiveParserWrapper extends ParserDecorator {
         EmbeddedParserDecorator decorator =
                 new EmbeddedParserDecorator(getWrappedParser(), "/", "/", parserState, metadata);
         context.set(Parser.class, decorator);
+        EmbeddedMetadataLookup preParseLookup = context.get(EmbeddedMetadataLookup.class);
+        context.set(EmbeddedMetadataLookup.class,
+                new EmbeddedMetadataLookup(parserState.recursiveParserWrapperHandler));
         ContentHandler localHandler =
                 parserState.recursiveParserWrapperHandler.createHandler();
         long started = System.currentTimeMillis();
@@ -181,6 +185,7 @@ public class RecursiveParserWrapper extends ParserDecorator {
             parserState.recursiveParserWrapperHandler.endDocument(localHandler, metadata);
             parserState.recursiveParserWrapperHandler.endDocument();
             context.set(RecursivelySecureContentHandler.class, null);
+            context.set(EmbeddedMetadataLookup.class, preParseLookup);
         }
     }
 
diff --git a/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHook.java b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHook.java
new file mode 100644
index 0000000000..2165044b19
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHook.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.hook;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Sees every document a parse touches, at the entry point that already sees each one once:
+ * the auto-detect parser, after detection. A hook is built at config load, carried in the
+ * {@link ParseContext} for the duration of the top-level parse, and may ask for a document's
+ * bytes after its own parser has run. Tika core knows nothing about what a hook does with
+ * them; an inference module does.
+ *
+ * @since Apache Tika 4.1
+ */
+public interface ParseHook {
+
+    /** Top of the top-level parse; a hook may refuse the request. */
+    default void start(Metadata root, ParseContext context) throws TikaException {
+    }
+
+    /** Whether this hook wants the bytes of a document of this detected type. */
+    default boolean wants(MediaType type, Metadata metadata, ParseContext context)
+            throws TikaException {
+        return false;
+    }
+
+    /**
+     * After the document's parser ran cleanly. {@code bytes} is the document pinned to a
+     * file for this call only; {@code parent} is the document it is embedded in, null at top
+     * level.
+     */
+    default void offer(MediaType type, Metadata metadata, Metadata parent, Path bytes,
+                       ParseContext context) throws IOException, TikaException {
+    }
+
+    /** End of the top-level parse; {@code failed} when the parse threw. */
+    default void end(Metadata root, boolean failed, ParseContext context) {
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
new file mode 100644
index 0000000000..53492addd2
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.hook;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.tika.config.TransientParseState;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * The hooks a parse runs, in config order. Pinning and offering never fail a document: a
+ * problem is a warning on that document's metadata.
+ *
+ * @since Apache Tika 4.1
+ */
+public final class ParseHooks implements TransientParseState {
+
+    private static final Logger LOG = LoggerFactory.getLogger(ParseHooks.class);
+
+    /** Marks a top-level parse in progress; tracks the document being parsed. */
+    public static final class Run implements TransientParseState {
+        private Metadata current;
+
+        /** Enters a document; returns the one whose parser embedded it, null at the top. */
+        public Metadata enter(Metadata metadata) {
+            Metadata parent = current;
+            current = metadata;
+            return parent;
+        }
+
+        public void exit(Metadata parent) {
+            current = parent;
+        }
+    }
+
+    private final List<ParseHook> hooks;
+
+    public ParseHooks(List<ParseHook> hooks) {
+        this.hooks = List.copyOf(hooks);
+    }
+
+    public List<ParseHook> getHooks() {
+        return hooks;
+    }
+
+    public boolean isEmpty() {
+        return hooks.isEmpty();
+    }
+
+    public void start(Metadata root, ParseContext context) throws TikaException {
+        for (ParseHook hook : hooks) {
+            hook.start(root, context);
+        }
+    }
+
+    /** The document's bytes as a file when a hook wants them, else null. */
+    public Path pin(MediaType type, Metadata metadata, TikaInputStream tis, ParseContext context)
+            throws TikaException {
+        boolean wanted = false;
+        for (ParseHook hook : hooks) {
+            wanted |= hook.wants(type, metadata, context);
+        }
+        if (!wanted) {
+            return null;
+        }
+        try {
+            return tis.getPath();
+        } catch (IOException e) {
+            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                    "parse hook: could not pin bytes: " + e.getMessage());
+            return null;
+        }
+    }
+
+    public void offer(MediaType type, Metadata metadata, Metadata parent, Path bytes,
+                      ParseContext context) {
+        for (ParseHook hook : hooks) {
+            try {
+                hook.offer(type, metadata, parent, bytes, context);
+            } catch (IOException | TikaException | RuntimeException e) {
+                LOG.warn("parse hook {} failed on {}", hook.getClass().getName(), type, e);
+                metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        "parse hook " + hook.getClass().getSimpleName() + ": " + e.getMessage());
+            }
+        }
+    }
+
+    public void end(Metadata root, boolean failed, ParseContext context) {
+        for (ParseHook hook : hooks) {
+            try {
+                hook.end(root, failed, context);
+            } catch (RuntimeException e) {
+                LOG.warn("parse hook {} failed at end", hook.getClass().getName(), e);
+                root.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        "parse hook " + hook.getClass().getSimpleName() + ": " + e.getMessage());
+            }
+        }
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceBinding.java b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceBinding.java
new file mode 100644
index 0000000000..90e8d86efb
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceBinding.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.inference;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.tika.mime.MediaType;
+
+/** One entry of the {@code "inference"} list: engine, input, tasks, filters, budget. */
+public final class InferenceBinding {
+
+    private final String id;
+    private final String engine;
+    private final InputKind input;
+    private final List<String> tasks;
+    private final Set<MediaType> include;
+    private final Set<MediaType> exclude;
+    private final int maxChunks;
+    private final long maxBytes;
+    private final boolean enabled;
+
+    public InferenceBinding(String id, String engine, InputKind input, List<String> tasks,
+                            Set<MediaType> include, Set<MediaType> exclude, int maxChunks,
+                            long maxBytes, boolean enabled) {
+        this.id = id;
+        this.engine = engine;
+        this.input = input;
+        this.tasks = List.copyOf(tasks);
+        this.include = include == null ? Collections.emptySet() : Set.copyOf(include);
+        this.exclude = exclude == null ? Collections.emptySet() : Set.copyOf(exclude);
+        this.maxChunks = maxChunks;
+        this.maxBytes = maxBytes;
+        this.enabled = enabled;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public String getEngine() {
+        return engine;
+    }
+
+    public InputKind getInput() {
+        return input;
+    }
+
+    public List<String> getTasks() {
+        return tasks;
+    }
+
+    /** Units this binding may enrich per document; -1 for no limit. */
+    public int getMaxChunks() {
+        return maxChunks;
+    }
+
+    /** Largest unit this binding accepts, in bytes; -1 for no limit. */
+    public long getMaxBytes() {
+        return maxBytes;
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    public boolean accepts(InputKind kind, MediaType type) {
+        if (kind != input || type == null) {
+            return false;
+        }
+        MediaType base = type.getBaseType();
+        if (!include.isEmpty() && !matches(include, base)) {
+            return false;
+        }
+        return !matches(exclude, base);
+    }
+
+    private static boolean matches(Set<MediaType> set, MediaType base) {
+        for (MediaType t : set) {
+            if (t.equals(base) || (t.getSubtype().equals("*") && t.getType().equals(base.getType()))) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
new file mode 100644
index 0000000000..a2d5147c49
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
@@ -0,0 +1,283 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.inference;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.config.TransientParseState;
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedMetadataLookup;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.hook.ParseHook;
+
+/**
+ * Matches offered units against the inference bindings and runs the tasks. Built once at
+ * config load and run as a {@link ParseHook}: every document, top-level or embedded, is
+ * offered once by the auto-detect parser. Units are buffered per binding for the whole top-level parse and flushed at its end, so a
+ * document is one request per binding, not one per unit; the buffer is bounded by each
+ * binding's {@code maxChunks} and {@code maxBytes}, and its bytes live in files the
+ * dispatcher owns until the flush. At the flush every unit is re-aimed at the metadata the
+ * recursive wrapper kept for its document, since the parser's own copy is no longer read. A
+ * request narrows the bindings through {@link InferenceSelection}. Failures mark the document
+ * and never fail the parse.
+ *
+ * @since Apache Tika 4.1
+ */
+public final class InferenceDispatcher implements ParseHook, TransientParseState {
+
+    private static final Logger LOG = LoggerFactory.getLogger(InferenceDispatcher.class);
+    private static final InferenceSelection ALL = new InferenceSelection();
+
+    /** A binding resolved to its engine and tasks. */
+    public record Bound(InferenceBinding binding, Engine engine, List<InferenceTask> tasks) {
+    }
+
+    private final List<Bound> bound;
+
+    public InferenceDispatcher(List<Bound> bound) {
+        this.bound = List.copyOf(bound);
+    }
+
+    public List<Bound> getBound() {
+        return bound;
+    }
+
+    /**
+     * Resolves the request's selection once, at the top of the parse, so a misnamed binding
+     * fails the request rather than one embedded document.
+     */
+    @Override
+    public void start(Metadata root, ParseContext context) throws TikaException {
+        state(context).selected(context, bound);
+    }
+
+    @Override
+    public boolean wants(MediaType type, Metadata metadata, ParseContext context)
+            throws TikaException {
+        InputKind kind = InputKind.of(type);
+        return kind != null && wants(kind, type, context);
+    }
+
+    @Override
+    public void offer(MediaType type, Metadata metadata, Metadata parent, Path bytes,
+                      ParseContext context) throws IOException, TikaException {
+        offer(InputKind.of(type), type, metadata, parent, bytes, context);
+    }
+
+    /** Runs the buffered units, or drops them when the parse failed: no engine call for a document nobody gets. */
+    @Override
+    public void end(Metadata root, boolean failed, ParseContext context) {
+        if (failed) {
+            discard(context);
+        } else {
+            flush(root, context);
+        }
+    }
+
+    /** Whether any binding that runs for this request takes this kind and type. */
+    public boolean wants(InputKind kind, MediaType type, ParseContext context)
+            throws TikaException {
+        for (Bound b : bound) {
+            if (runs(b.binding(), context) && b.binding().accepts(kind, type)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Buffers the bytes at {@code source} for every binding that takes them, copied into a
+     * file the dispatcher owns; dropped past a binding's budget.
+     */
+    public void offer(InputKind kind, MediaType type, Metadata target, Metadata parent,
+                      Path source, ParseContext context) throws IOException, TikaException {
+        State state = state(context);
+        long size = Files.size(source);
+        InferenceUnit unit = null;
+        for (Bound b : bound) {
+            InferenceBinding binding = b.binding();
+            if (!runs(binding, context) || !binding.accepts(kind, type)) {
+                continue;
+            }
+            if (binding.getMaxBytes() >= 0 && size > binding.getMaxBytes()) {
+                state.dropped.merge(binding.getId() + " over maxBytes", 1, Integer::sum);
+                continue;
+            }
+            List<InferenceUnit> units = state.byBinding.computeIfAbsent(binding.getId(),
+                    k -> new ArrayList<>());
+            if (binding.getMaxChunks() >= 0 && units.size() >= binding.getMaxChunks()) {
+                state.dropped.merge(binding.getId() + " over maxChunks", 1, Integer::sum);
+                continue;
+            }
+            if (unit == null) {
+                Path copy = state.tmp.createTempFile();
+                Files.copy(source, copy, StandardCopyOption.REPLACE_EXISTING);
+                unit = new InferenceUnit(kind, type, target, parent, copy);
+            }
+            units.add(unit);
+        }
+    }
+
+    /** Runs every binding's tasks over its buffered units; clears the buffer and its files. */
+    public void flush(Metadata root, ParseContext context) {
+        State state = context.get(State.class);
+        if (state == null) {
+            return;
+        }
+        context.set(State.class, null);
+        try {
+            EmbeddedMetadataLookup lookup = context.get(EmbeddedMetadataLookup.class);
+            for (Bound b : bound) {
+                List<InferenceUnit> units = state.byBinding.get(b.binding().getId());
+                if (units == null || units.isEmpty()) {
+                    continue;
+                }
+                List<InferenceUnit> kept = retarget(units, lookup);
+                for (InferenceTask task : b.tasks()) {
+                    try {
+                        task.run(b.binding(), kept, b.engine(), context);
+                    } catch (Exception e) {
+                        LOG.warn("inference binding {} failed", b.binding().getId(), e);
+                        root.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                                "inference binding " + b.binding().getId() + ": " + e.getMessage());
+                    }
+                }
+            }
+            for (Map.Entry<String, Integer> e : state.dropped.entrySet()) {
+                root.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        "inference binding " + e.getKey() + ": skipped " + e.getValue() + " units");
+            }
+        } finally {
+            try {
+                state.tmp.close();
+            } catch (IOException e) {
+                LOG.warn("could not delete inference temp files", e);
+            }
+        }
+    }
+
+    /** Clears the buffer and its files without running anything. */
+    public void discard(ParseContext context) {
+        State state = context.get(State.class);
+        if (state == null) {
+            return;
+        }
+        context.set(State.class, null);
+        try {
+            state.tmp.close();
+        } catch (IOException e) {
+            LOG.warn("could not delete inference temp files", e);
+        }
+    }
+
+    /** Re-aims units at the metadata the wrapper kept; the live object where nothing is kept. */
+    private static List<InferenceUnit> retarget(List<InferenceUnit> units,
+                                                EmbeddedMetadataLookup lookup) {
+        if (lookup == null) {
+            return units;
+        }
+        List<InferenceUnit> kept = new ArrayList<>(units.size());
+        for (InferenceUnit unit : units) {
+            Metadata target = lookup.kept(unit.getTargetIdPath());
+            Metadata parent = lookup.kept(unit.getParentIdPath());
+            kept.add(unit.retargeted(target != null ? target : unit.getTarget(),
+                    parent != null ? parent : unit.getParent()));
+        }
+        return kept;
+    }
+
+    private boolean runs(InferenceBinding binding, ParseContext context) throws TikaException {
+        if (!binding.isEnabled()) {
+            return false;
+        }
+        Set<String> selected = state(context).selected(context, bound);
+        return selected == null || selected.contains(binding.getId());
+    }
+
+    private static State state(ParseContext context) {
+        State state = context.get(State.class);
+        if (state == null) {
+            state = new State();
+            context.set(State.class, state);
+        }
+        return state;
+    }
+
+    /** Per-parse buffer; lives in the context from the first offer to the flush. */
+    static final class State implements TransientParseState {
+        final Map<String, List<InferenceUnit>> byBinding = new LinkedHashMap<>();
+        final Map<String, Integer> dropped = new LinkedHashMap<>();
+        final TemporaryResources tmp = new TemporaryResources();
+        private Set<String> selected;
+        private boolean resolved;
+
+        /** The binding ids this request runs, resolved once; null means every enabled one. */
+        Set<String> selected(ParseContext context, List<Bound> bound) throws TikaException {
+            if (resolved) {
+                return selected;
+            }
+            InferenceSelection selection = context.get(InferenceSelection.class);
+            if (selection == null) {
+                try {
+                    selection = ParseContextConfig.getConfig(context, "inference",
+                            InferenceSelection.class, ALL);
+                } catch (TikaConfigException | IOException e) {
+                    throw new TikaException("invalid \"inference\" in parse-context", e);
+                }
+            }
+            Set<String> result;
+            if (!selection.isEnabled()) {
+                result = Set.of();
+            } else if (selection.getBindings().isEmpty()) {
+                result = null;
+            } else {
+                Set<String> known = new HashSet<>();
+                for (Bound b : bound) {
+                    known.add(b.binding().getId());
+                }
+                for (String id : selection.getBindings()) {
+                    if (!known.contains(id)) {
+                        throw new TikaException("\"inference\" in parse-context names binding \""
+                                + id + "\", which is not configured; configured: " + known);
+                    }
+                }
+                result = new HashSet<>(selection.getBindings());
+            }
+            selected = result;
+            resolved = true;
+            return selected;
+        }
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceSelection.java b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceSelection.java
new file mode 100644
index 0000000000..6cd8297610
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceSelection.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.inference;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.tika.annotation.TikaComponent;
+
+/**
+ * Per-request choice among the configured bindings, under {@code "inference"} in
+ * {@code parse-context}: {@code enabled: false} runs none; {@code bindings} names the ones
+ * that run, every enabled binding when absent. Names only, so it is wire-safe; a name not
+ * in the configured list fails the request.
+ */
+@TikaComponent(name = "inference", spi = false)
+public class InferenceSelection implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private boolean enabled = true;
+    private List<String> bindings = new ArrayList<>();
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    public void setEnabled(boolean enabled) {
+        this.enabled = enabled;
+    }
+
+    public List<String> getBindings() {
+        return bindings;
+    }
+
+    public void setBindings(List<String> bindings) {
+        this.bindings = bindings == null ? new ArrayList<>() : new ArrayList<>(bindings);
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
new file mode 100644
index 0000000000..7feb80c60a
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.inference;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * What a binding asks of its engine, named in the binding's {@code "tasks"}: one call per
+ * unit for {@code embed}, all units in one call for a document-level task. The task owns
+ * its output shape and writes it onto each unit's target (or parent).
+ */
+public interface InferenceTask {
+
+    /** Rejects an engine this task cannot use; called once at config load. */
+    default void validate(InferenceBinding binding, Engine engine) throws TikaConfigException {
+    }
+
+    void run(InferenceBinding binding, List<InferenceUnit> units, Engine engine,
+             ParseContext context) throws IOException, TikaException;
+}
diff --git a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
new file mode 100644
index 0000000000..96d4b085b5
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.inference;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+
+/**
+ * One thing offered to the dispatcher: the bytes of an image, a page render, a media clip,
+ * with the document they came from and, when known, its parent. Captured at the offer so
+ * a task can run after the document has closed; the bytes live in a file the dispatcher
+ * owns until the flush, and the id paths let the flush find the metadata the recursive
+ * wrapper kept once the parser's own objects are no longer read.
+ */
+public final class InferenceUnit {
+
+    private final InputKind kind;
+    private final MediaType type;
+    private final Metadata target;
+    private final Metadata parent;
+    private final String targetIdPath;
+    private final String parentIdPath;
+    private final Path path;
+    private final long size;
+
+    public InferenceUnit(InputKind kind, MediaType type, Metadata target, Metadata parent,
+                         Path path) throws IOException {
+        this(kind, type, target, parent, target.get(TikaCoreProperties.EMBEDDED_ID_PATH),
+                parent == null ? null : parent.get(TikaCoreProperties.EMBEDDED_ID_PATH), path,
+                Files.size(path));
+    }
+
+    private InferenceUnit(InputKind kind, MediaType type, Metadata target, Metadata parent,
+                          String targetIdPath, String parentIdPath, Path path, long size) {
+        this.kind = kind;
+        this.type = type;
+        this.target = target;
+        this.parent = parent;
+        this.targetIdPath = targetIdPath;
+        this.parentIdPath = parentIdPath;
+        this.path = path;
+        this.size = size;
+    }
+
+    /** The same unit aimed at the metadata objects that are still read. */
+    InferenceUnit retargeted(Metadata target, Metadata parent) {
+        return new InferenceUnit(kind, type, target, parent, targetIdPath, parentIdPath, path,
+                size);
+    }
+
+    public InputKind getKind() {
+        return kind;
+    }
+
+    public MediaType getType() {
+        return type;
+    }
+
+    /** The document the bytes belong to. */
+    public Metadata getTarget() {
+        return target;
+    }
+
+    /** The document the target is embedded in; null at top level. */
+    public Metadata getParent() {
+        return parent;
+    }
+
+    public String getTargetIdPath() {
+        return targetIdPath;
+    }
+
+    public String getParentIdPath() {
+        return parentIdPath;
+    }
+
+    public Path getPath() {
+        return path;
+    }
+
+    public long getSize() {
+        return size;
+    }
+
+    /** Readable during a task run only; the file is deleted when the flush ends. */
+    public byte[] getBytes() throws IOException {
+        return Files.readAllBytes(path);
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/parser/inference/InputKind.java b/tika-core/src/main/java/org/apache/tika/parser/inference/InputKind.java
new file mode 100644
index 0000000000..af1d6a1fd3
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/InputKind.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.inference;
+
+import org.apache.tika.mime.MediaType;
+
+/** What a binding is fed: a document's text, its rendered pages, an image, or media bytes. */
+public enum InputKind {
+    TEXT, PAGES, IMAGES, MEDIA;
+
+    /** The kind a document's bytes are, by media type family; null for the rest. */
+    public static InputKind of(MediaType type) {
+        if (type == null) {
+            return null;
+        }
+        switch (type.getType()) {
+            case "image":
+                return IMAGES;
+            case "audio":
+            case "video":
+                return MEDIA;
+            default:
+                return null;
+        }
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/sax/AbstractRecursiveParserWrapperHandler.java b/tika-core/src/main/java/org/apache/tika/sax/AbstractRecursiveParserWrapperHandler.java
index ac6c8d4eed..982d3b9b1d 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/AbstractRecursiveParserWrapperHandler.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/AbstractRecursiveParserWrapperHandler.java
@@ -100,6 +100,17 @@ public abstract class AbstractRecursiveParserWrapperHandler extends DefaultHandl
         metadata.set(TikaCoreProperties.EMBEDDED_DEPTH, 0);
     }
 
+    /**
+     * The metadata this handler kept for an embedded document, by its
+     * {@code tk:embedded-id-path}, or null when it keeps none: a component that writes
+     * results after the document has ended writes there, not onto the parser's copy.
+     *
+     * @since Apache Tika 4.1
+     */
+    public Metadata getEmbeddedMetadata(String idPath) {
+        return null;
+    }
+
     public ContentHandlerFactory getContentHandlerFactory() {
         return contentHandlerFactory;
     }
diff --git a/tika-core/src/main/java/org/apache/tika/sax/RecursiveParserWrapperHandler.java b/tika-core/src/main/java/org/apache/tika/sax/RecursiveParserWrapperHandler.java
index c9452a38ce..b4038c63aa 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/RecursiveParserWrapperHandler.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/RecursiveParserWrapperHandler.java
@@ -141,6 +141,16 @@ public class RecursiveParserWrapperHandler extends AbstractRecursiveParserWrappe
      * @return a list of Metadata objects, one for the main document and one for each embedded
      * document
      */
+    @Override
+    public Metadata getEmbeddedMetadata(String idPath) {
+        for (Metadata m : metadataList) {
+            if (idPath.equals(m.get(TikaCoreProperties.EMBEDDED_ID_PATH))) {
+                return m;
+            }
+        }
+        return null;
+    }
+
     public List<Metadata> getMetadataList() {
         return metadataList;
     }
diff --git a/tika-core/src/test/java/org/apache/tika/parser/hook/ParseHooksTest.java b/tika-core/src/test/java/org/apache/tika/parser/hook/ParseHooksTest.java
new file mode 100644
index 0000000000..046e66fd17
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/parser/hook/ParseHooksTest.java
@@ -0,0 +1,293 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.hook;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.detect.Detector;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.extractor.ParentMetadata;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.RecursiveParserWrapper;
+import org.apache.tika.sax.BasicContentHandlerFactory;
+import org.apache.tika.sax.RecursiveParserWrapperHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+public class ParseHooksTest {
+
+    private static final MediaType PNG = MediaType.image("png");
+    private static final MediaType CONTAINER = MediaType.application("x-test-container");
+
+    /** Detection by the declared type: these tests are about the seam, not the detector. */
+    private static final Detector DECLARED = (tis, metadata, ctx) ->
+            MediaType.parse(metadata.get(HttpHeaders.CONTENT_TYPE));
+
+    static final class RecordingHook implements ParseHook {
+        final List<String> events = new ArrayList<>();
+        final List<Metadata> offeredParents = new ArrayList<>();
+        final List<byte[]> offeredBytes = new ArrayList<>();
+        Set<MediaType> wanted = Set.of(PNG);
+        boolean failOffer;
+        boolean failStart;
+
+        @Override
+        public void start(Metadata root, ParseContext context) throws TikaException {
+            if (failStart) {
+                throw new TikaException("bad selection");
+            }
+            events.add("start");
+        }
+
+        @Override
+        public boolean wants(MediaType type, Metadata metadata, ParseContext context) {
+            return wanted.contains(type);
+        }
+
+        @Override
+        public void offer(MediaType type, Metadata metadata, Metadata parent, Path bytes,
+                          ParseContext context) throws IOException {
+            if (failOffer) {
+                throw new IOException("no");
+            }
+            events.add("offer " + type);
+            offeredParents.add(parent);
+            offeredBytes.add(Files.readAllBytes(bytes));
+        }
+
+        @Override
+        public void end(Metadata root, boolean failed, ParseContext context) {
+            events.add(failed ? "end failed" : "end");
+        }
+    }
+
+    static final class TypedParser implements Parser {
+        private final MediaType type;
+        private final boolean fail;
+
+        TypedParser(MediaType type, boolean fail) {
+            this.type = type;
+            this.fail = fail;
+        }
+
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return Collections.singleton(type);
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
+                          ParseContext context) throws IOException, SAXException, TikaException {
+            if (fail) {
+                throw new TikaException("broken");
+            }
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
+            xhtml.startDocument();
+            xhtml.endDocument();
+        }
+    }
+
+    /** A container whose one child is a PNG, parsed through the embedded extractor. */
+    static final class ContainerParser implements Parser {
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return Collections.singleton(CONTAINER);
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
+                          ParseContext context) throws IOException, SAXException, TikaException {
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
+            xhtml.startDocument();
+            Metadata child = new Metadata();
+            child.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
+            child.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "INLINE");
+            try (TikaInputStream childStream = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context)
+                        .parseEmbedded(childStream, xhtml, child, context, false);
+            }
+            xhtml.endDocument();
+        }
+    }
+
+    private static AutoDetectParser parser(RecordingHook hook, Parser... parsers) {
+        AutoDetectParser adp = new AutoDetectParser(DECLARED, parsers);
+        adp.setParseHooks(new ParseHooks(List.of(hook)));
+        return adp;
+    }
+
+    private static Metadata typed(MediaType type) {
+        Metadata m = new Metadata();
+        m.set(HttpHeaders.CONTENT_TYPE, type.toString());
+        return m;
+    }
+
+    @Test
+    public void testTopLevelDocumentIsOfferedBetweenStartAndEnd() throws Exception {
+        RecordingHook hook = new RecordingHook();
+        Metadata metadata = typed(PNG);
+        ParseContext context = new ParseContext();
+        try (TikaInputStream tis = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+            parser(hook, new TypedParser(PNG, false)).parse(tis, new DefaultHandler(), metadata, context);
+        }
+        assertEquals(List.of("start", "offer image/png", "end"), hook.events);
+        assertArrayEquals("PNG-BYTES".getBytes(UTF_8), hook.offeredBytes.get(0));
+        assertNull(hook.offeredParents.get(0), "top level has no parent");
+        assertNull(context.get(ParseHooks.class), "hooks are unseeded after the parse");
+        assertNull(context.get(ParseHooks.Run.class));
+    }
+
+    @Test
+    public void testEmbeddedDocumentIsOfferedOnceWithItsParent() throws Exception {
+        for (boolean recursive : new boolean[]{false, true}) {
+            RecordingHook hook = new RecordingHook();
+            AutoDetectParser adp = parser(hook, new ContainerParser(), new TypedParser(PNG, false));
+            Metadata root = typed(CONTAINER);
+            ParseContext context = new ParseContext();
+            try (TikaInputStream tis = TikaInputStream.get("CONTAINER".getBytes(UTF_8))) {
+                if (recursive) {
+                    new RecursiveParserWrapper(adp).parse(tis, new RecursiveParserWrapperHandler(
+                            new BasicContentHandlerFactory(
+                                    BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1)),
+                            root, context);
+                } else {
+                    adp.parse(tis, new DefaultHandler(), root, context);
+                }
+            }
+            assertEquals(List.of("start", "offer image/png", "end"), hook.events,
+                    "recursive=" + recursive + ": one offer for the child, none for the container");
+            assertSame(root, hook.offeredParents.get(0),
+                    "recursive=" + recursive + ": the child's parent is the root");
+        }
+    }
+
+    @Test
+    public void testUnwantedTypeIsNotPinned() throws Exception {
+        RecordingHook hook = new RecordingHook();
+        hook.wanted = Set.of();
+        try (TikaInputStream tis = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+            parser(hook, new TypedParser(PNG, false)).parse(tis, new DefaultHandler(), typed(PNG),
+                    new ParseContext());
+        }
+        assertEquals(List.of("start", "end"), hook.events);
+    }
+
+    @Test
+    public void testFailedParseEndsAsFailedAndOffersNothing() throws Exception {
+        RecordingHook hook = new RecordingHook();
+        Metadata metadata = typed(PNG);
+        try (TikaInputStream tis = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+            assertThrows(TikaException.class, () -> parser(hook, new TypedParser(PNG, true))
+                    .parse(tis, new DefaultHandler(), metadata, new ParseContext()));
+        }
+        assertEquals(List.of("start", "end failed"), hook.events);
+    }
+
+    @Test
+    public void testOfferFailureIsAWarningNotAFailure() throws Exception {
+        RecordingHook hook = new RecordingHook();
+        hook.failOffer = true;
+        Metadata metadata = typed(PNG);
+        try (TikaInputStream tis = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+            parser(hook, new TypedParser(PNG, false)).parse(tis, new DefaultHandler(), metadata,
+                    new ParseContext());
+        }
+        assertEquals(List.of("start", "end"), hook.events);
+        assertTrue(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING).contains("no"));
+    }
+
+    @Test
+    public void testStartFailureFailsTheParseAndRestoresTheContext() throws Exception {
+        RecordingHook hook = new RecordingHook();
+        hook.failStart = true;
+        ParseContext context = new ParseContext();
+        try (TikaInputStream tis = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+            assertThrows(TikaException.class, () -> parser(hook, new TypedParser(PNG, false))
+                    .parse(tis, new DefaultHandler(), typed(PNG), context));
+        }
+        assertEquals(List.of("end failed"), hook.events);
+        assertNull(context.get(ParseHooks.class));
+        assertNull(context.get(ParseHooks.Run.class));
+    }
+
+    @Test
+    public void testNoHooksNoTrace() throws Exception {
+        AutoDetectParser adp = new AutoDetectParser(DECLARED, new TypedParser(PNG, false));
+        ParseContext context = new ParseContext();
+        try (TikaInputStream tis = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+            adp.parse(tis, new DefaultHandler(), typed(PNG), context);
+        }
+        assertNull(context.get(ParseHooks.class));
+    }
+
+    /** The chunk lift reads ParentMetadata during a child's parse: the seam must not shadow it. */
+    @Test
+    public void testParentMetadataIsLeftToTheWrapper() throws Exception {
+        RecordingHook hook = new RecordingHook();
+        List<Metadata> seenByImageParser = new ArrayList<>();
+        Parser png = new Parser() {
+            @Override
+            public Set<MediaType> getSupportedTypes(ParseContext context) {
+                return Collections.singleton(PNG);
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
+                              ParseContext context) throws IOException, SAXException {
+                ParentMetadata parent = context.get(ParentMetadata.class);
+                seenByImageParser.add(parent == null ? null : parent.getMetadata());
+                new XHTMLContentHandler(handler, metadata, context).startDocument();
+            }
+        };
+        AutoDetectParser adp = parser(hook, new ContainerParser(), png);
+        Metadata root = typed(CONTAINER);
+        try (TikaInputStream tis = TikaInputStream.get("CONTAINER".getBytes(UTF_8))) {
+            new RecursiveParserWrapper(adp).parse(tis, new RecursiveParserWrapperHandler(
+                    new BasicContentHandlerFactory(
+                            BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1)), root,
+                    new ParseContext());
+        }
+        assertEquals(1, seenByImageParser.size());
+        assertSame(root, seenByImageParser.get(0), "the picture's parent, not the picture");
+    }
+}
diff --git a/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
new file mode 100644
index 0000000000..36e68b03b3
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
@@ -0,0 +1,362 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.inference;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.detect.Detector;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.extractor.EmbeddedMetadataLookup;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.RecursiveParserWrapper;
+import org.apache.tika.parser.hook.ParseHooks;
+import org.apache.tika.sax.AbstractRecursiveParserWrapperHandler;
+import org.apache.tika.sax.BasicContentHandlerFactory;
+import org.apache.tika.sax.RecursiveParserWrapperHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+public class InferenceDispatcherTest {
+
+    private static final MediaType PNG = MediaType.image("png");
+    private static final MediaType CONTAINER = MediaType.application("x-test-container");
+
+    static final class RecordingEngine implements Engine {
+    }
+
+    /** Records units and their bytes; bytes are only readable during the run. */
+    static final class RecordingTask implements InferenceTask {
+        final List<List<InferenceUnit>> runs = new ArrayList<>();
+        final List<List<byte[]>> bytes = new ArrayList<>();
+
+        @Override
+        public void run(InferenceBinding binding, List<InferenceUnit> units, Engine engine,
+                        ParseContext context) throws IOException {
+            runs.add(List.copyOf(units));
+            List<byte[]> read = new ArrayList<>();
+            for (InferenceUnit unit : units) {
+                read.add(unit.getBytes());
+            }
+            bytes.add(read);
+        }
+    }
+
+    /** A stub parser for a type: emits an empty document. */
+    static final class TypedParser implements Parser {
+        private final Set<MediaType> types;
+
+        TypedParser(MediaType type) {
+            this.types = Collections.singleton(type);
+        }
+
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return types;
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
+                          ParseContext context) throws IOException, SAXException, TikaException {
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
+            xhtml.startDocument();
+            xhtml.endDocument();
+        }
+    }
+
+    /** A container whose one child is a PNG, parsed through the embedded extractor. */
+    static final class ContainerParser implements Parser {
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return Collections.singleton(CONTAINER);
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
+                          ParseContext context) throws IOException, SAXException, TikaException {
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
+            xhtml.startDocument();
+            Metadata child = new Metadata();
+            child.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
+            child.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "INLINE");
+            child.set(TikaCoreProperties.RESOURCE_NAME_KEY, "image1.png");
+            try (TikaInputStream childStream = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context)
+                        .parseEmbedded(childStream, xhtml, child, context, false);
+            }
+            xhtml.endDocument();
+        }
+    }
+
+    private static InferenceBinding binding(String id, InputKind kind, Set<MediaType> include,
+                                            int maxChunks) {
+        return new InferenceBinding(id, "engine", kind, List.of("embed"), include, null,
+                maxChunks, -1, true);
+    }
+
+    @TempDir
+    Path tmp;
+
+    private Path file(String bytes) throws IOException {
+        Path p = Files.createTempFile(tmp, "unit", ".bin");
+        Files.write(p, bytes.getBytes(UTF_8));
+        return p;
+    }
+
+    private void offer(InferenceDispatcher dispatcher, MediaType type, String bytes,
+                       ParseContext context) throws Exception {
+        dispatcher.offer(InputKind.IMAGES, type, new Metadata(), null, file(bytes), context);
+    }
+
+    @Test
+    public void testBufferPerBindingAndBudget() throws Exception {
+        RecordingEngine engine = new RecordingEngine();
+        RecordingTask pngTask = new RecordingTask();
+        RecordingTask jpegTask = new RecordingTask();
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(binding("png", InputKind.IMAGES, Set.of(PNG), 1),
+                        engine, List.of(pngTask)),
+                new InferenceDispatcher.Bound(
+                        binding("jpeg", InputKind.IMAGES, Set.of(MediaType.image("jpeg")), -1),
+                        engine, List.of(jpegTask))));
+        ParseContext context = new ParseContext();
+        assertTrue(dispatcher.wants(InputKind.IMAGES, PNG, context));
+        assertTrue(!dispatcher.wants(InputKind.PAGES, PNG, context));
+        assertTrue(!dispatcher.wants(InputKind.IMAGES, MediaType.image("gif"), context));
+
+        offer(dispatcher, PNG, "a", context);
+        offer(dispatcher, PNG, "b", context);
+        offer(dispatcher, MediaType.image("jpeg"), "c", context);
+        offer(dispatcher, MediaType.image("gif"), "d", context);
+        assertTrue(pngTask.runs.isEmpty(), "nothing runs before the flush");
+        Path held = context.get(InferenceDispatcher.State.class).byBinding.get("png").get(0).getPath();
+        assertTrue(Files.exists(held), "the dispatcher owns a copy until the flush");
+
+        Metadata root = new Metadata();
+        dispatcher.flush(root, context);
+        assertEquals(1, pngTask.runs.size());
+        assertEquals(1, pngTask.runs.get(0).size(), "maxChunks 1 keeps the first unit");
+        assertArrayEquals("a".getBytes(UTF_8), pngTask.bytes.get(0).get(0));
+        assertEquals(1, jpegTask.runs.get(0).size());
+        assertTrue(root.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)
+                .contains("skipped 1 units"));
+        assertNull(context.get(InferenceDispatcher.State.class), "the buffer is cleared");
+        assertFalse(Files.exists(held), "and its files are gone");
+
+        dispatcher.flush(root, context);
+        assertEquals(1, pngTask.runs.size(), "a second flush has nothing to run");
+    }
+
+    @Test
+    public void testSelectionAndMaxBytes() throws Exception {
+        RecordingTask pngTask = new RecordingTask();
+        RecordingTask smallTask = new RecordingTask();
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(binding("png", InputKind.IMAGES, null, -1),
+                        new RecordingEngine(), List.of(pngTask)),
+                new InferenceDispatcher.Bound(new InferenceBinding("small", "engine",
+                        InputKind.IMAGES, List.of("embed"), null, null, -1, 2, true),
+                        new RecordingEngine(), List.of(smallTask))));
+
+        ParseContext off = new ParseContext();
+        InferenceSelection selection = new InferenceSelection();
+        selection.setEnabled(false);
+        off.set(InferenceSelection.class, selection);
+        assertFalse(dispatcher.wants(InputKind.IMAGES, PNG, off));
+
+        ParseContext some = new ParseContext();
+        selection = new InferenceSelection();
+        selection.setBindings(List.of("small"));
+        some.set(InferenceSelection.class, selection);
+        offer(dispatcher, PNG, "abc", some);
+        offer(dispatcher, PNG, "ab", some);
+        dispatcher.flush(new Metadata(), some);
+        assertTrue(pngTask.runs.isEmpty(), "not selected for this request");
+        assertEquals(1, smallTask.runs.get(0).size(), "maxBytes 2 drops the 3-byte unit");
+
+        ParseContext unknown = new ParseContext();
+        selection = new InferenceSelection();
+        selection.setBindings(List.of("nope"));
+        unknown.set(InferenceSelection.class, selection);
+        assertThrows(TikaException.class, () -> dispatcher.wants(InputKind.IMAGES, PNG, unknown));
+    }
+
+    /** A handler that kept a copy of one embedded document, the way the wrapper does. */
+    static final class KeepingHandler extends AbstractRecursiveParserWrapperHandler {
+        final Metadata kept = new Metadata();
+
+        KeepingHandler(String idPath) {
+            super(new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1));
+            kept.set(TikaCoreProperties.EMBEDDED_ID_PATH, idPath);
+        }
+
+        @Override
+        public Metadata getEmbeddedMetadata(String idPath) {
+            return idPath.equals(kept.get(TikaCoreProperties.EMBEDDED_ID_PATH)) ? kept : null;
+        }
+    }
+
+    @Test
+    public void testFlushWritesToTheKeptMetadata() throws Exception {
+        RecordingTask task = new RecordingTask();
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(binding("png", InputKind.IMAGES, null, -1),
+                        new RecordingEngine(), List.of(task))));
+        KeepingHandler handler = new KeepingHandler("/1");
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedMetadataLookup.class, new EmbeddedMetadataLookup(handler));
+
+        Metadata liveParent = new Metadata();
+        liveParent.set(TikaCoreProperties.EMBEDDED_ID_PATH, "/1");
+        Metadata liveChild = new Metadata();
+        liveChild.set(TikaCoreProperties.EMBEDDED_ID_PATH, "/1/2");
+        dispatcher.offer(InputKind.IMAGES, PNG, liveChild, liveParent, file("x"), context);
+        dispatcher.flush(new Metadata(), context);
+
+        InferenceUnit unit = task.runs.get(0).get(0);
+        assertSame(handler.kept, unit.getParent(), "the parent the wrapper kept, not the live one");
+        assertSame(liveChild, unit.getTarget(), "nothing kept for the child: the live object");
+        assertEquals("/1/2", unit.getTargetIdPath());
+    }
+
+    /** Detection by the declared type: these tests are about the hook, not the detector. */
+    private static final Detector DECLARED = (tis, metadata, ctx) ->
+            MediaType.parse(metadata.get(HttpHeaders.CONTENT_TYPE));
+
+    private static AutoDetectParser hooked(InferenceDispatcher dispatcher, Parser... parsers) {
+        AutoDetectParser adp = new AutoDetectParser(DECLARED, parsers);
+        adp.setParseHooks(new ParseHooks(List.of(dispatcher)));
+        return adp;
+    }
+
+    private static InferenceDispatcher pngDispatcher(RecordingTask task) {
+        return new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(binding("png", InputKind.IMAGES, null, -1),
+                        new RecordingEngine(), List.of(task))));
+    }
+
+    @Test
+    public void testUnknownSelectionFailsTheTopLevelParse() throws Exception {
+        AutoDetectParser parser = hooked(pngDispatcher(new RecordingTask()), new TypedParser(PNG));
+        ParseContext context = new ParseContext();
+        InferenceSelection selection = new InferenceSelection();
+        selection.setBindings(List.of("typo"));
+        context.set(InferenceSelection.class, selection);
+        Metadata metadata = new Metadata();
+        metadata.set(HttpHeaders.CONTENT_TYPE, "text/plain");
+        try (TikaInputStream tis = TikaInputStream.get("not an image".getBytes(UTF_8))) {
+            assertThrows(TikaException.class,
+                    () -> parser.parse(tis, new DefaultHandler(), metadata, context),
+                    "fails at the top of the parse, before any document is offered");
+        }
+    }
+
+    @Test
+    public void testNestedChildOutsideTheWrapperGetsItsParent() throws Exception {
+        RecordingTask task = new RecordingTask();
+        AutoDetectParser parser = hooked(pngDispatcher(task), new ContainerParser(),
+                new TypedParser(PNG));
+        Metadata root = new Metadata();
+        root.set(HttpHeaders.CONTENT_TYPE, CONTAINER.toString());
+        ParseContext context = new ParseContext();
+        try (TikaInputStream tis = TikaInputStream.get("CONTAINER".getBytes(UTF_8))) {
+            parser.parse(tis, new DefaultHandler(), root, context);
+        }
+        assertEquals(1, task.runs.size());
+        assertSame(root, task.runs.get(0).get(0).getParent(),
+                "the parent is named in every mode, not only under the wrapper");
+        assertNull(context.get(ParseHooks.class), "seeding is undone at the top level");
+    }
+
+    @Test
+    public void testOffersAndFlushesAtTopLevel() throws Exception {
+        RecordingTask task = new RecordingTask();
+        AutoDetectParser parser = hooked(pngDispatcher(task), new TypedParser(PNG));
+        Metadata metadata = new Metadata();
+        metadata.set(HttpHeaders.CONTENT_TYPE, "image/png");
+        ParseContext context = new ParseContext();
+        try (TikaInputStream tis = TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+            parser.parse(tis, new DefaultHandler(), metadata, context);
+        }
+        assertEquals(1, task.runs.size());
+        InferenceUnit unit = task.runs.get(0).get(0);
+        assertArrayEquals("PNG-BYTES".getBytes(UTF_8), task.bytes.get(0).get(0));
+        assertSame(metadata, unit.getTarget());
+        assertNull(unit.getParent());
+    }
+
+    @Test
+    public void testNestedChildCarriesItsParent() throws Exception {
+        RecordingTask task = new RecordingTask();
+        AutoDetectParser parser = hooked(pngDispatcher(task), new ContainerParser(),
+                new TypedParser(PNG));
+        RecursiveParserWrapper wrapper = new RecursiveParserWrapper(parser);
+        Metadata root = new Metadata();
+        root.set(HttpHeaders.CONTENT_TYPE, CONTAINER.toString());
+        RecursiveParserWrapperHandler handler = new RecursiveParserWrapperHandler(
+                new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1));
+        try (TikaInputStream tis = TikaInputStream.get("CONTAINER".getBytes(UTF_8))) {
+            wrapper.parse(tis, handler, root, new ParseContext());
+        }
+        assertEquals(1, task.runs.size(), "flushed once, at the end of the top-level parse");
+        InferenceUnit unit = task.runs.get(0).get(0);
+        assertArrayEquals("PNG-BYTES".getBytes(UTF_8), task.bytes.get(0).get(0));
+        assertEquals("image1.png", unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertSame(root, unit.getParent());
+        assertEquals(2, handler.getMetadataList().size());
+    }
+
+    @Test
+    public void testFailedParseRunsNothingAndCleansUp() throws Exception {
+        RecordingTask task = new RecordingTask();
+        InferenceDispatcher dispatcher = pngDispatcher(task);
+        ParseContext context = new ParseContext();
+        offer(dispatcher, PNG, "a", context);
+        Path held = context.get(InferenceDispatcher.State.class).byBinding.get("png").get(0).getPath();
+        dispatcher.end(new Metadata(), true, context);
+        assertTrue(task.runs.isEmpty(), "a failed document costs no engine call");
+        assertFalse(Files.exists(held));
+        assertNull(context.get(InferenceDispatcher.State.class));
+    }
+}
diff --git a/tika-core/src/test/java/org/apache/tika/parser/mock/MockEngine.java b/tika-core/src/test/java/org/apache/tika/parser/mock/MockEngine.java
new file mode 100644
index 0000000000..bfab3b20a5
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/parser/mock/MockEngine.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.mock;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.parser.inference.Engine;
+
+/** Fixture for {@code "engines"}: nameable as {@code mock-engine}, calls nothing. */
+@TikaComponent(name = "mock-engine", spi = false)
+public class MockEngine implements Engine {
+}
diff --git a/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java b/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
new file mode 100644
index 0000000000..ea7266c946
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.mock;
+
+import java.util.List;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.inference.Engine;
+import org.apache.tika.parser.inference.InferenceBinding;
+import org.apache.tika.parser.inference.InferenceTask;
+import org.apache.tika.parser.inference.InferenceUnit;
+
+/**
+ * Fixture for {@code "inference"} tasks: nameable as {@code mock-task}, marks each unit's
+ * parent (or the unit itself) with the binding id and the number of units it saw at once.
+ */
+@TikaComponent(name = "mock-task", spi = false)
+public class MockTask implements InferenceTask {
+
+    public static final String MARKER_KEY = "mock-task";
+    public static final String UNITS_KEY = "mock-task-units";
+
+    @Override
+    public void run(InferenceBinding binding, List<InferenceUnit> units, Engine engine,
+                    ParseContext context) {
+        for (InferenceUnit unit : units) {
+            Metadata onto = unit.getParent() != null ? unit.getParent() : unit.getTarget();
+            onto.set(MARKER_KEY, binding.getId());
+            onto.set(UNITS_KEY, Integer.toString(units.size()));
+        }
+    }
+}
diff --git a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
index e7b4088705..53592873cc 100644
--- a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
+++ b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
@@ -48,12 +48,17 @@ public final class ChunkTarget {
 
     public static ChunkTarget resolve(Metadata target, ParseContext context) {
         ParentMetadata parent = context.get(ParentMetadata.class);
+        return resolve(target, parent == null ? null : parent.getMetadata());
+    }
+
+    /** As above, with the parent captured earlier (the dispatcher runs after the walk). */
+    public static ChunkTarget resolve(Metadata target, Metadata parent) {
         String idPath = target.get(TikaCoreProperties.EMBEDDED_ID_PATH);
         String type = target.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE);
         if (parent == null || idPath == null || type == null || !LIFTED.contains(type)) {
             return new ChunkTarget(target, null);
         }
-        return new ChunkTarget(parent.getMetadata(),
+        return new ChunkTarget(parent,
                 new EmbeddedLocator(idPath, target.get(TikaCoreProperties.RESOURCE_NAME_KEY)));
     }
 
diff --git a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
new file mode 100644
index 0000000000..333dde4a22
--- /dev/null
+++ b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.inference;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.inference.locator.Locators;
+import org.apache.tika.inference.locator.PaginatedLocator;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.TikaPagedText;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.inference.Engine;
+import org.apache.tika.parser.inference.InferenceBinding;
+import org.apache.tika.parser.inference.InferenceTask;
+import org.apache.tika.parser.inference.InferenceUnit;
+import org.apache.tika.parser.inference.InputKind;
+
+/**
+ * The {@code embed} task: one vector chunk per unit, written where the unit's chunks belong
+ * (its parent for an inline picture or a page render, itself otherwise), with the page
+ * locator when the unit has a page. Units go to the engine in batches of its size.
+ */
+@TikaComponent(name = "embed", spi = false)
+public class EmbedTask implements InferenceTask {
+
+    @Override
+    public void validate(InferenceBinding binding, Engine engine) throws TikaConfigException {
+        if (!(engine instanceof EmbeddingEngine)) {
+            throw new TikaConfigException("task \"embed\" needs an embedding engine; \""
+                    + binding.getEngine() + "\" is not one");
+        }
+        if (binding.getInput() != InputKind.IMAGES) {
+            throw new TikaConfigException("task \"embed\" takes IMAGES; binding \""
+                    + binding.getId() + "\" is on " + binding.getInput());
+        }
+    }
+
+    @Override
+    public void run(InferenceBinding binding, List<InferenceUnit> units, Engine engine,
+                    ParseContext context) throws IOException, TikaException {
+        EmbeddingEngine embedder = (EmbeddingEngine) engine;
+        int batchSize = Math.max(1, embedder.getMaxBatchSize());
+        TikaException first = null;
+        for (int start = 0; start < units.size(); start += batchSize) {
+            List<InferenceUnit> batch = units.subList(start, Math.min(start + batchSize, units.size()));
+            try {
+                embed(batch, embedder, context);
+            } catch (TikaException e) {
+                if (batch.size() == 1) {
+                    first = first == null ? e : first;
+                    continue;
+                }
+                // one bad image fails a whole batch; retry singly so the rest keep their vectors
+                for (InferenceUnit unit : batch) {
+                    try {
+                        embed(List.of(unit), embedder, context);
+                    } catch (TikaException single) {
+                        first = first == null ? single : first;
+                    }
+                }
+            }
+        }
+        if (first != null) {
+            throw first;
+        }
+    }
+
+    private static void embed(List<InferenceUnit> batch, EmbeddingEngine embedder,
+                              ParseContext context) throws IOException, TikaException {
+        List<byte[]> images = new ArrayList<>(batch.size());
+        List<String> mimeTypes = new ArrayList<>(batch.size());
+        for (InferenceUnit unit : batch) {
+            images.add(unit.getBytes());
+            mimeTypes.add(unit.getType().getBaseType().toString());
+        }
+        List<float[]> vectors = embedder.embedImages(images, mimeTypes, context);
+        for (int i = 0; i < batch.size(); i++) {
+            InferenceUnit unit = batch.get(i);
+            Locators locators = new Locators();
+            String page = unit.getTarget().get(TikaPagedText.PAGE_NUMBER);
+            if (page != null) {
+                locators.addPaginated(new PaginatedLocator(Integer.parseInt(page)));
+            }
+            Chunk chunk = new Chunk(null, locators);
+            chunk.setVector(vectors.get(i));
+            ChunkTarget.resolve(unit.getTarget(), unit.getParent())
+                    .write(List.of(chunk), TikaCoreProperties.TIKA_CHUNKS.getName());
+        }
+    }
+}
diff --git a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
index bc04bd4954..0dc5595dc7 100644
--- a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
+++ b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
@@ -185,6 +185,9 @@ public class OpenAIImageEmbeddingParser implements Parser, Initializable, Closea
 
     @Override
     public void initialize() throws TikaConfigException {
+        LOG.info("openai-image-embedding-parser runs one request per image; the \"engines\" + "
+                + "\"inference\" shape (openai-embedding-engine, input IMAGES, task embed) batches "
+                + "a document's images into one request");
         this.httpClient = TikaHttpClient.build(30);
     }
 
diff --git a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
new file mode 100644
index 0000000000..49d3015748
--- /dev/null
+++ b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.inference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.http.TikaTestHttpServer;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.TikaPagedText;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.inference.Engine;
+import org.apache.tika.parser.inference.InferenceBinding;
+import org.apache.tika.parser.inference.InferenceUnit;
+import org.apache.tika.parser.inference.InputKind;
+
+public class EmbedTaskTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private TikaTestHttpServer server;
+    private OpenAIEmbeddingEngine engine;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        server = new TikaTestHttpServer();
+        engine = new OpenAIEmbeddingEngine();
+        engine.setBaseUrl(server.url());
+        engine.setModel("clip");
+        engine.setMaxBatchSize(2);
+        engine.initialize();
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        engine.close();
+        server.shutdown();
+    }
+
+    private static String response(int... dims) {
+        StringBuilder sb = new StringBuilder("{\"data\":[");
+        for (int i = 0; i < dims.length; i++) {
+            // out of order on purpose: the task must place by index
+            int index = dims.length - 1 - i;
+            sb.append(i > 0 ? "," : "").append("{\"index\":").append(index)
+                    .append(",\"embedding\":[").append(index).append(".0,0.5]}");
+        }
+        return sb.append("]}").toString();
+    }
+
+    @TempDir
+    Path tmp;
+
+    private InferenceUnit unit(Metadata parent, String name, String page) throws Exception {
+        Metadata target = new Metadata();
+        target.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "INLINE");
+        target.set(TikaCoreProperties.EMBEDDED_ID_PATH, "/" + name);
+        target.set(TikaCoreProperties.RESOURCE_NAME_KEY, name + ".png");
+        if (page != null) {
+            target.set(TikaPagedText.PAGE_NUMBER, page);
+        }
+        Path file = Files.createTempFile(tmp, name, ".png");
+        Files.write(file, name.getBytes(StandardCharsets.UTF_8));
+        return new InferenceUnit(InputKind.IMAGES, MediaType.image("png"), target, parent, file);
+    }
+
+    private static InferenceBinding binding() {
+        return new InferenceBinding("pics", "clip", InputKind.IMAGES, List.of("embed"), null,
+                null, -1, -1, true);
+    }
+
+    @Test
+    public void testBatchesByEngineSizeAndPlacesByIndex() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0, 1)));
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
+        Metadata parent = new Metadata();
+        List<InferenceUnit> units = List.of(unit(parent, "a", "1"), unit(parent, "b", null),
+                unit(parent, "c", null));
+
+        new EmbedTask().run(binding(), units, engine, new ParseContext());
+
+        assertEquals(2, server.getRequestCount(), "three units, batch size two");
+        JsonNode first = MAPPER.readTree(server.takeRequest().body());
+        assertEquals("clip", first.get("model").asText());
+        assertEquals(2, first.get("input").size());
+        assertEquals("data:image/png;base64,YQ==", first.get("input").get(0).get("image").asText());
+
+        List<Chunk> chunks = ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS));
+        assertEquals(3, chunks.size());
+        assertEquals(0.0f, chunks.get(0).getVector()[0], "unit a got index 0 despite the order");
+        assertEquals(1.0f, chunks.get(1).getVector()[0]);
+        assertEquals(1, chunks.get(0).getLocators().getPaginated().get(0).getPage());
+        assertEquals("a.png", chunks.get(0).getLocators().getEmbedded().get(0).getName());
+        assertEquals("/c", chunks.get(2).getLocators().getEmbedded().get(0).getIdPath());
+        for (InferenceUnit unit : units) {
+            assertNull(unit.getTarget().get(TikaCoreProperties.TIKA_CHUNKS), "moved to the parent");
+        }
+    }
+
+    @Test
+    public void testNoParentKeepsTheChunk() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
+        InferenceUnit unit = unit(null, "solo", null);
+        new EmbedTask().run(binding(), List.of(unit), engine, new ParseContext());
+        assertEquals(1, ChunkSerializer.fromJson(
+                unit.getTarget().get(TikaCoreProperties.TIKA_CHUNKS)).size());
+    }
+
+    @Test
+    public void testValidateRejectsANonEmbeddingEngine() {
+        assertThrows(TikaConfigException.class,
+                () -> new EmbedTask().validate(binding(), new Engine() { }));
+        InferenceBinding media = new InferenceBinding("clips", "clip", InputKind.MEDIA,
+                List.of("embed"), null, null, -1, -1, true);
+        assertThrows(TikaConfigException.class, () -> new EmbedTask().validate(media, engine),
+                "embed takes IMAGES only");
+    }
+
+    @Test
+    public void testDuplicateIndexIsRejected() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200,
+                "{\"data\":[{\"index\":0,\"embedding\":[1.0]},{\"index\":0,\"embedding\":[2.0]}]}"));
+        Metadata parent = new Metadata();
+        assertThrows(org.apache.tika.exception.TikaException.class, () -> new EmbedTask().run(
+                binding(), List.of(unit(parent, "a", null), unit(parent, "b", null)), engine,
+                new ParseContext()));
+    }
+
+    @Test
+    public void testFailedBatchRetriesUnitsSingly() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(400, "{\"error\":\"bad image\"}"));
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
+        server.enqueue(new TikaTestHttpServer.MockResponse(400, "{\"error\":\"bad image\"}"));
+        Metadata parent = new Metadata();
+        List<InferenceUnit> units = List.of(unit(parent, "good", null), unit(parent, "bad", null));
+        assertThrows(org.apache.tika.exception.TikaException.class,
+                () -> new EmbedTask().run(binding(), units, engine, new ParseContext()),
+                "the failure is still reported");
+        assertEquals(3, server.getRequestCount(), "one batch, then one request per unit");
+        assertEquals(1, ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS)).size(),
+                "the good unit keeps its vector");
+    }
+
+    @Test
+    public void testResponseCountMustMatch() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
+        Metadata parent = new Metadata();
+        assertThrows(org.apache.tika.exception.TikaException.class, () -> new EmbedTask().run(
+                binding(), List.of(unit(parent, "a", null), unit(parent, "b", null)), engine,
+                new ParseContext()));
+    }
+}
diff --git a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
index 9543fc32c1..b805aba5fd 100644
--- a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
+++ b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
@@ -46,6 +46,7 @@ import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.mock.MockEnricher;
+import org.apache.tika.parser.mock.MockTask;
 import org.apache.tika.pipes.api.FetchEmitTuple;
 import org.apache.tika.pipes.api.ParseMode;
 import org.apache.tika.pipes.api.PipesResult;
@@ -84,6 +85,31 @@ public class PipesClientTest {
         }
     }
 
+    /** Wire test for the inference bindings: the dispatcher rides the fork's own config. */
+    @Test
+    public void testInferenceBindingInFork(@TempDir Path tmp) throws Exception {
+        Path tikaConfigPath = PluginsTestHelper.getFileSystemFetcherConfig(
+                "tika-config-inference-bindings.json", tmp, tmp.resolve("input"),
+                tmp.resolve("output"), false);
+        Path inputDir = tmp.resolve("input");
+        Files.createDirectories(inputDir);
+        java.awt.image.BufferedImage image =
+                new java.awt.image.BufferedImage(10, 10, java.awt.image.BufferedImage.TYPE_INT_RGB);
+        javax.imageio.ImageIO.write(image, "png", inputDir.resolve("test.png").toFile());
+
+        TikaJsonConfig tikaJsonConfig = TikaJsonConfig.load(tikaConfigPath);
+        PipesConfig pipesConfig = PipesConfig.load(tikaJsonConfig);
+        try (PipesClient pipesClient = new PipesClient(pipesConfig, tikaConfigPath)) {
+            PipesResult pipesResult = pipesClient.process(
+                    new FetchEmitTuple("test.png", new FetchKey(fetcherName, "test.png"),
+                            new EmitKey(), new Metadata(), new ParseContext(),
+                            FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+            Metadata metadata = pipesResult.emitData().getMetadataList().get(0);
+            assertEquals("mock-images", metadata.get(MockTask.MARKER_KEY));
+            assertEquals("1", metadata.get(MockTask.UNITS_KEY));
+        }
+    }
+
     /**
      * Wire test for the text-recognizers slot (TIKA-4872): a config-named enricher is
      * injected into the fork's parsers and its output survives the fork boundary.
diff --git a/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-inference-bindings.json b/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-inference-bindings.json
new file mode 100644
index 0000000000..90b215fb4f
--- /dev/null
+++ b/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-inference-bindings.json
@@ -0,0 +1,57 @@
+{
+  "engines": { "mock": { "mock-engine": {} } },
+  "inference": [ { "id": "mock-images", "engine": "mock", "input": "IMAGES", "tasks": ["mock-task"] } ],
+  "content-handler-factory": {
+    "basic-content-handler-factory": {
+      "type": "TEXT",
+      "writeLimit": -1,
+      "throwOnWriteLimitReached": true
+    }
+  },
+  "fetchers": {
+    "fsf": {
+      "file-system-fetcher": {
+        "basePath": "FETCHER_BASE_PATH",
+        "extractFileSystemMetadata": false
+      }
+    }
+  },
+  "emitters": {
+    "fse": {
+      "file-system-emitter": {
+        "basePath": "EMITTER_BASE_PATH",
+        "fileExtension": "json",
+        "onExists": "EXCEPTION"
+      }
+    }
+  },
+  "pipes-iterator": {
+    "file-system-pipes-iterator": {
+      "basePath": "FETCHER_BASE_PATH",
+      "countTotal": true,
+      "fetcherId": "fsf",
+      "emitterId": "fse"
+    }
+  },
+  "pipes": {
+    "parseMode": "RMETA",
+    "onParseException": "EMIT",
+    "numClients": 4,
+    "emitIntermediateResults": "EMIT_INTERMEDIATE_RESULTS",
+    "forkedJvmArgs": ["-Xmx512m"],
+    "emitStrategy": {
+      "type": "DYNAMIC",
+      "thresholdBytes": 1000000
+    }
+  },
+  "auto-detect-parser": {
+    "throwOnZeroBytes": false
+  },
+  "parse-context": {
+    "mock-digester-factory": {},
+    "timeout-limits": {
+      "progressTimeoutMillis": 5000
+    }
+  },
+  "plugin-roots": "PLUGINS_PATHS"
+}
diff --git a/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java b/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
new file mode 100644
index 0000000000..3a55a76c02
--- /dev/null
+++ b/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.enricher.ContentEnrichers;
+import org.apache.tika.parser.inference.Engine;
+import org.apache.tika.parser.inference.EngineRegistry;
+import org.apache.tika.parser.inference.InferenceBinding;
+import org.apache.tika.parser.inference.InferenceDispatcher;
+import org.apache.tika.parser.inference.InferenceTask;
+import org.apache.tika.parser.inference.InputKind;
+
+/**
+ * Loads {@code "inference"}: bindings of a named engine to an input kind and tasks. Every
+ * engine and task name is resolved at load, so a misnamed one fails startup. Null when the
+ * key is absent.
+ */
+class InferenceLoader implements ComponentLoader<InferenceDispatcher> {
+
+    static final String KEY = "inference";
+    private static final Set<String> KNOWN = Set.of("id", "engine", "input", "tasks",
+            "maxChunks", "maxBytes", "enabled", "_mime-include", "_mime-exclude");
+    private static final Pattern LEGAL_ID = Pattern.compile("[A-Za-z0-9._-]+");
+    /** Largest unit a binding takes unless it says otherwise. */
+    static final long DEFAULT_MAX_BYTES = 20L * 1024 * 1024;
+    /** Image types no embedding endpoint takes; a binding's own include list overrides. */
+    private static final List<String> NON_RASTER = List.of("image/svg+xml", "image/vnd.dwg",
+            "image/vnd.dxf", "image/x-emf", "image/x-wmf", "image/wmf", "image/emf",
+            "image/vnd.adobe.photoshop", "image/x-photoshop", "image/vnd.microsoft.icon",
+            "image/x-icon");
+
+    @Override
+    public InferenceDispatcher load(TikaJsonConfig config, LoaderContext context)
+            throws TikaConfigException {
+        JsonNode node = config.getRootNode().get(KEY);
+        if (node == null) {
+            return null;
+        }
+        if (!node.isArray()) {
+            throw new TikaConfigException("\"" + KEY + "\" must be an array of bindings");
+        }
+        EngineRegistry engines = context.get(EngineRegistry.class);
+        List<InferenceDispatcher.Bound> bound = new ArrayList<>();
+        Set<String> ids = new HashSet<>();
+        for (JsonNode entry : node) {
+            if (!entry.isObject()) {
+                throw new TikaConfigException("\"" + KEY + "\" entries must be objects");
+            }
+            Iterator<String> names = entry.fieldNames();
+            while (names.hasNext()) {
+                String name = names.next();
+                if (!KNOWN.contains(name)) {
+                    throw new TikaConfigException("\"" + KEY + "\" entry has unknown key \""
+                            + name + "\"; known: " + KNOWN);
+                }
+            }
+            String engineName = required(entry, "engine");
+            InputKind input;
+            try {
+                input = InputKind.valueOf(required(entry, "input").toUpperCase(Locale.ROOT));
+            } catch (IllegalArgumentException e) {
+                throw new TikaConfigException("\"input\" must be one of TEXT, PAGES, IMAGES, MEDIA");
+            }
+            String id = entry.hasNonNull("id") ? entry.get("id").asText()
+                    : engineName + "-" + input.name().toLowerCase(Locale.ROOT);
+            if (!LEGAL_ID.matcher(id).matches()) {
+                throw new TikaConfigException("binding id \"" + id + "\" may use only letters, "
+                        + "digits, '.', '_' and '-'");
+            }
+            if (!ids.add(id)) {
+                throw new TikaConfigException("\"" + KEY + "\" binding id \"" + id
+                        + "\" is used twice");
+            }
+            Engine engine = engines == null ? null : engines.get(engineName);
+            if (engine == null) {
+                throw new TikaConfigException("binding \"" + id + "\" names engine \""
+                        + engineName + "\", which is not in \"engines\"");
+            }
+            List<String> taskNames = new ArrayList<>();
+            if (entry.has("tasks")) {
+                JsonNode tasks = entry.get("tasks");
+                if (!tasks.isArray() || tasks.isEmpty()) {
+                    throw new TikaConfigException("binding \"" + id + "\": \"tasks\" must be a "
+                            + "non-empty array of task names");
+                }
+                for (JsonNode t : tasks) {
+                    if (!t.isTextual()) {
+                        throw new TikaConfigException("binding \"" + id + "\": task names must "
+                                + "be strings");
+                    }
+                    taskNames.add(t.asText());
+                }
+            } else {
+                taskNames.add("embed");
+            }
+            int maxChunks = bounded(entry, "maxChunks", -1, id).intValue();
+            long maxBytes = bounded(entry, "maxBytes", DEFAULT_MAX_BYTES, id);
+            Set<MediaType> include = mimeTypes(entry, "_mime-include", id);
+            Set<MediaType> exclude = mimeTypes(entry, "_mime-exclude", id);
+            if (input == InputKind.IMAGES && include.isEmpty() && exclude.isEmpty()) {
+                for (String t : NON_RASTER) {
+                    exclude.add(MediaType.parse(t));
+                }
+            }
+            InferenceBinding binding = new InferenceBinding(id, engineName, input, taskNames,
+                    include, exclude, maxChunks, maxBytes,
+                    entry.path("enabled").asBoolean(true));
+            List<InferenceTask> tasks = new ArrayList<>();
+            for (String taskName : taskNames) {
+                InferenceTask task;
+                try {
+                    task = ComponentInstantiator.instantiateComponent(taskName,
+                            context.getObjectMapper().createObjectNode(),
+                            context.getObjectMapper(), context.getClassLoader(),
+                            InferenceTask.class);
+                } catch (TikaConfigException e) {
+                    throw new TikaConfigException("binding \"" + id + "\" task \"" + taskName
+                            + "\": " + e.getMessage(), e);
+                }
+                task.validate(binding, engine);
+                tasks.add(task);
+            }
+            bound.add(new InferenceDispatcher.Bound(binding, engine, tasks));
+        }
+        return new InferenceDispatcher(bound);
+    }
+
+    private static String required(JsonNode entry, String field) throws TikaConfigException {
+        if (!entry.hasNonNull(field) || !entry.get(field).isTextual()) {
+            throw new TikaConfigException("\"" + KEY + "\" entry needs \"" + field + "\"");
+        }
+        return entry.get(field).asText();
+    }
+
+    /** A budget: -1 for no limit, 0 for none, else the value; anything below -1 is refused. */
+    private static Long bounded(JsonNode entry, String field, long dflt, String id)
+            throws TikaConfigException {
+        if (!entry.has(field)) {
+            return dflt;
+        }
+        JsonNode v = entry.get(field);
+        if (!v.isIntegralNumber() || v.asLong() < -1) {
+            throw new TikaConfigException("binding \"" + id + "\": \"" + field
+                    + "\" must be -1 (no limit) or a non-negative integer");
+        }
+        return v.asLong();
+    }
+
+    private static Set<MediaType> mimeTypes(JsonNode entry, String field, String id)
+            throws TikaConfigException {
+        Set<MediaType> types = new HashSet<>();
+        if (!entry.has(field)) {
+            return types;
+        }
+        if (!entry.get(field).isArray()) {
+            throw new TikaConfigException("binding \"" + id + "\": \"" + field
+                    + "\" must be an array of media types");
+        }
+        for (JsonNode t : entry.get(field)) {
+            MediaType type = MediaType.parse(t.asText());
+            if (type == null || ContentEnrichers.isLegacyOcrType(type)) {
+                throw new TikaConfigException("binding \"" + id + "\": \"" + field
+                        + "\" has an invalid media type " + t.asText());
+            }
+            types.add(type);
+        }
+        return types;
+    }
+}
diff --git a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
index 04fcf89073..b806b1b5eb 100644
--- a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
+++ b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
@@ -115,6 +115,7 @@ public class TikaJsonConfig {
             "renderers",
             "text-recognizers",
             "engines",
+            "inference",
             "translator",
             "auto-detect-parser",
             "parse-context",
diff --git a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
index 594f93f815..8cf2b44c91 100644
--- a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
+++ b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
@@ -53,7 +53,9 @@ import org.apache.tika.parser.CompositeParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.enricher.CompositeContentEnricher;
+import org.apache.tika.parser.hook.ParseHooks;
 import org.apache.tika.parser.inference.EngineRegistry;
+import org.apache.tika.parser.inference.InferenceDispatcher;
 import org.apache.tika.renderer.CompositeRenderer;
 import org.apache.tika.renderer.Renderer;
 import org.apache.tika.sax.BasicContentHandlerFactory;
@@ -142,6 +144,9 @@ public class TikaLoader {
         ComponentConfig.builder(EngineLoader.KEY, EngineRegistry.class)
                 .customLoader(new EngineLoader())
                 .register();
+        ComponentConfig.builder(InferenceLoader.KEY, InferenceDispatcher.class)
+                .customLoader(new InferenceLoader())
+                .register();
 
         ComponentConfig.builder("translator", Translator.class)
                 .loadAsList()
@@ -411,6 +416,11 @@ public class TikaLoader {
                 adpConfig = new AutoDetectParserConfig();
             }
             autoDetectParser = AutoDetectParser.build((CompositeParser)loadParsers(), loadDetectors(), adpConfig);
+            InferenceDispatcher dispatcher = get(InferenceDispatcher.class);
+            if (dispatcher != null) {
+                ((AutoDetectParser) autoDetectParser)
+                        .setParseHooks(new ParseHooks(List.of(dispatcher)));
+            }
         }
         return autoDetectParser;
     }
diff --git a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
new file mode 100644
index 0000000000..22a2cf2c7e
--- /dev/null
+++ b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.inference.EngineRegistry;
+import org.apache.tika.parser.inference.InferenceDispatcher;
+import org.apache.tika.parser.inference.InferenceSelection;
+import org.apache.tika.parser.inference.InputKind;
+
+public class InferenceLoaderTest {
+
+    private static final String ENGINES = "\"engines\": {"
+            + " \"one\": { \"test-engine\": { \"label\": \"first\" } },"
+            + " \"two\": { \"test-engine\": { \"label\": \"second\" } } }";
+
+    @TempDir
+    Path tmp;
+
+    private TikaLoader load(String json) throws Exception {
+        Path config = Files.createTempFile(tmp, "tika-config", ".json");
+        Files.writeString(config, json);
+        return TikaLoader.load(config);
+    }
+
+    @Test
+    public void testEnginesAndBindingsLoad() throws Exception {
+        TikaLoader loader = load("{" + ENGINES + ", \"inference\": ["
+                + " { \"id\": \"pngs\", \"engine\": \"one\", \"input\": \"IMAGES\","
+                + "   \"tasks\": [\"test-task\"], \"_mime-include\": [\"image/png\"],"
+                + "   \"maxChunks\": 3 },"
+                + " { \"engine\": \"two\", \"input\": \"images\", \"tasks\": [\"test-task\"],"
+                + "   \"enabled\": false } ] }");
+        EngineRegistry engines = loader.get(EngineRegistry.class);
+        assertEquals("first", ((TestEngine) engines.get("one")).getLabel());
+        assertEquals("second", ((TestEngine) engines.get("two")).getLabel());
+
+        InferenceDispatcher dispatcher = loader.get(InferenceDispatcher.class);
+        assertEquals(2, dispatcher.getBound().size());
+        InferenceDispatcher.Bound pngs = dispatcher.getBound().get(0);
+        assertEquals("pngs", pngs.binding().getId());
+        assertSame(engines.get("one"), pngs.engine());
+        assertEquals(3, pngs.binding().getMaxChunks());
+        assertEquals(InferenceLoader.DEFAULT_MAX_BYTES, pngs.binding().getMaxBytes());
+        assertTrue(pngs.binding().accepts(InputKind.IMAGES, MediaType.image("png")));
+        assertFalse(pngs.binding().accepts(InputKind.IMAGES, MediaType.image("jpeg")));
+        InferenceDispatcher.Bound second = dispatcher.getBound().get(1);
+        assertEquals("two-images", second.binding().getId(), "default id is engine-input");
+        assertFalse(second.binding().isEnabled());
+        assertTrue(second.binding().accepts(InputKind.IMAGES, MediaType.image("png")));
+        assertFalse(second.binding().accepts(InputKind.IMAGES, MediaType.image("svg+xml")),
+                "without an include list, non-raster image types are excluded by default");
+
+        AutoDetectParser parser = (AutoDetectParser) loader.loadAutoDetectParser();
+        assertEquals(List.of(dispatcher), parser.getParseHooks().getHooks(),
+                "the dispatcher rides every parse as a hook");
+    }
+
+    @Test
+    public void testPerRequestSelectionResolves() throws Exception {
+        TikaLoader loader = load("{" + ENGINES + ", \"inference\": ["
+                + " { \"id\": \"pngs\", \"engine\": \"one\", \"input\": \"IMAGES\","
+                + "   \"tasks\": [\"test-task\"] } ],"
+                + " \"parse-context\": { \"inference\": { \"bindings\": [\"pngs\"],"
+                + "   \"enabled\": true } } }");
+        InferenceSelection selection = loader.loadParseContext().get(InferenceSelection.class);
+        assertNotNull(selection, "the parse-context block resolves to the class-keyed DTO");
+        assertEquals(List.of("pngs"), selection.getBindings());
+        assertTrue(selection.isEnabled());
+    }
+
+    @Test
+    public void testAbsentSectionsLoadNothing() throws Exception {
+        TikaLoader loader = load("{ \"parsers\": [ { \"default-parser\": {} } ] }");
+        assertNull(loader.get(EngineRegistry.class));
+        assertNull(loader.get(InferenceDispatcher.class));
+        assertNull(((AutoDetectParser) loader.loadAutoDetectParser()).getParseHooks());
+    }
+
+    @Test
+    public void testMisconfigurationsFailLoad() throws Exception {
+        String[] bad = {
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"nope\", \"input\": \"IMAGES\" } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": [\"no-such-task\"] } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"SLIDES\" } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": [\"test-task\"] }, { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": [\"test-task\"] } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": [\"test-task\"], \"when\": \"always\" } ] }",
+            "{ \"engines\": { \"one\": { \"test-engine\": {}, \"test-engine-2\": {} } } }",
+            "{ \"engines\": { \"bad name\": { \"test-engine\": {} } } }",
+            "{ \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\" } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": [] } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": \"test-task\" } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": [\"test-task\"], \"maxChunks\": -2 } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"id\": \"bad id\", \"engine\": \"one\","
+                    + " \"input\": \"IMAGES\", \"tasks\": [\"test-task\"] } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": [\"test-task\"], \"_mime-include\": \"image/png\" } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", \"input\": \"IMAGES\","
+                    + " \"tasks\": [\"test-task\"], \"chunker\": { \"identity\": {} } } ] }",
+        };
+        for (String json : bad) {
+            TikaConfigException e = assertThrows(TikaConfigException.class, () -> {
+                TikaLoader loader = load(json);
+                loader.get(EngineRegistry.class);
+                loader.get(InferenceDispatcher.class);
+            }, json);
+            assertNotNull(e.getMessage());
+        }
+    }
+}
diff --git a/tika-serialization/src/test/java/org/apache/tika/config/loader/TestTask.java b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestTask.java
new file mode 100644
index 0000000000..d8c28996ab
--- /dev/null
+++ b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestTask.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import java.util.List;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.inference.Engine;
+import org.apache.tika.parser.inference.InferenceBinding;
+import org.apache.tika.parser.inference.InferenceTask;
+import org.apache.tika.parser.inference.InferenceUnit;
+
+/** A task that accepts only a {@link TestEngine} and records nothing. */
+@TikaComponent(name = "test-task", spi = false)
+public class TestTask implements InferenceTask {
+
+    @Override
+    public void validate(InferenceBinding binding, Engine engine) throws TikaConfigException {
+        if (!(engine instanceof TestEngine)) {
+            throw new TikaConfigException("test-task needs a test-engine");
+        }
+    }
+
+    @Override
+    public void run(InferenceBinding binding, List<InferenceUnit> units, Engine engine,
+                    ParseContext context) {
+    }
+}
diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
index bd53a8ba81..e21913a1f9 100644
--- a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
+++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
@@ -362,6 +362,13 @@ public abstract class CXFTestBase {
             if (contentEnrichers != null) {
                 root.set("text-recognizers", contentEnrichers);
             }
+            // the worker builds its own engines and bindings from its own config
+            for (String section : new String[]{"engines", "inference"}) {
+                JsonNode node = tikaConfig.get(section);
+                if (node != null) {
+                    root.set(section, node);
+                }
+            }
             // The worker resolves preset names from its own config, so presets must be
             // visible there just like the parse-context defaults above.
             JsonNode presets = tikaConfig.get("presets");
diff --git a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
new file mode 100644
index 0000000000..0b04515ea4
--- /dev/null
+++ b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.server.standard;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+import javax.imageio.ImageIO;
+
+import jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.mock.MockTask;
+import org.apache.tika.serialization.JsonMetadataList;
+import org.apache.tika.server.core.CXFTestBase;
+import org.apache.tika.server.core.resource.RecursiveMetadataResource;
+import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter;
+
+/** A config-named enricher reaches the forked worker and its output comes back (TIKA-4872). */
+public class InferenceBindingsWireTest extends CXFTestBase {
+
+    private static final String META_PATH = "/rmeta";
+
+    @Override
+    protected void setUpResources(JAXRSServerFactoryBean sf) {
+        sf.setResourceClasses(RecursiveMetadataResource.class);
+        sf.setResourceProvider(RecursiveMetadataResource.class,
+                new SingletonResourceProvider(new RecursiveMetadataResource(tikaResource)));
+    }
+
+    @Override
+    protected void setUpProviders(JAXRSServerFactoryBean sf) {
+        List<Object> providers = new ArrayList<>();
+        providers.add(new MetadataListMessageBodyWriter());
+        sf.setProviders(providers);
+    }
+
+    @Override
+    protected InputStream getTikaConfigInputStream() {
+        return getClass().getResourceAsStream("/configs/tika-config-inference-bindings.json");
+    }
+
+    @Test
+    public void testBindingOutputSurvivesFork() throws Exception {
+        ByteArrayOutputStream png = new ByteArrayOutputStream();
+        ImageIO.write(new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB), "png", png);
+
+        Response response = WebClient
+                .create(endPoint + META_PATH)
+                .accept("application/json")
+                .put(png.toByteArray());
+
+        Reader reader = new InputStreamReader((InputStream) response.getEntity(), UTF_8);
+        List<Metadata> metadataList = JsonMetadataList.fromJson(reader);
+        assertEquals(1, metadataList.size());
+        Metadata metadata = metadataList.get(0);
+        assertEquals("mock-images", metadata.get(MockTask.MARKER_KEY));
+        assertEquals("1", metadata.get(MockTask.UNITS_KEY));
+    }
+}
diff --git a/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json b/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
new file mode 100644
index 0000000000..445057c367
--- /dev/null
+++ b/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
@@ -0,0 +1,6 @@
+{
+  "engines": { "mock": { "mock-engine": {} } },
+  "inference": [ { "id": "mock-images", "engine": "mock", "input": "IMAGES", "tasks": ["mock-task"] } ],
+  "auto-detect-parser": { "throwOnZeroBytes": false },
+  "parse-context": { "timeout-limits": { "progressTimeoutMillis": 30000 } }
+}