> Markdown version of [Request Interception](https://vaadin.com/docs/next/flow/ai-support/request-interception). Section index: [llms.txt](https://vaadin.com/docs/next/flow/llms.txt)

# Request Interception (since V25.3)

A request interceptor receives the user’s message text and attachments before the orchestrator acts on them. It runs before the message appears in the Message List, before controller and request listener hooks, before the conversation history entry, and before the orchestrator builds the LLM request. Whatever the interceptor leaves in place is the only version that exists downstream. That makes it the right place to enforce a content policy, mask personal data before it leaves the server, convert an uploaded file to a format the model accepts, or run each prompt past a moderation service.

The interceptor covers every way a prompt enters the orchestrator — a submit through a connected input component as well as the programmatic `prompt()` calls. Prompts whose text is blank are dropped before the interceptor runs.

## <a id="registering-an-interceptor"></a>Registering an Interceptor

Register the interceptor with `withRequestInterceptor()` on the builder. The event it receives carries the message text and attachments. Change them, or reject the prompt:

```java
AIOrchestrator orchestrator = AIOrchestrator
        .builder(provider, systemPrompt)
        .withMessageList(messageList)
        .withInput(messageInput)
        .withRequestInterceptor(event -> {
            if (violatesContentPolicy(event.getUserMessage())) {
                event.reject("This request isn't something the "
                        + "assistant can help with.");
                return;
            }
            event.setUserMessage(maskPersonalData(event.getUserMessage()));
        })
        .build();
```

`violatesContentPolicy` and `maskPersonalData` are placeholders for application code. The first decides whether the prompt may go out at all, and the second replaces personal data in the text with placeholders.

The interceptor runs on the UI thread under the session lock, and the orchestrator uses its result as soon as it returns — keep synchronous work short. Slow work such as a remote moderation call belongs in [asynchronous preprocessing](#asynchronous-preprocessing) instead.

> **Tip: Testing an Interceptor**
>
> `RequestInterceptEvent` has a public constructor, so an interceptor can be unit-tested without an orchestrator: create an event with the test’s message and attachments, pass it to the interceptor, and assert on the event afterwards.

## <a id="rejecting-a-prompt"></a>Rejecting a Prompt

Calling `event.reject()` cancels the prompt without feedback. Nothing reaches the LLM, nothing shows up in the Message List or the conversation history, and no controller or request listener hooks fire. To the rest of the application, the prompt never happened.

Calling `event.reject(userFacingMessage)` cancels the prompt the same way, but shows the exchange in the Message List: the user’s message and attachments as originally submitted — unaffected by any replacements — then the given message under the assistant name. The user sees what was rejected and why. Neither entry becomes part of the conversation history. Without a configured Message List, nothing is shown anywhere.

Rejection is final — changing the message or the attachments afterwards doesn’t undo it. A rejected prompt doesn’t block the conversation: the orchestrator accepts the next one right away.

> **Note: Rejected Exchanges Are Not in the History**
>
> The two Message List entries produced by `reject(userFacingMessage)` exist only in the component, not in `getHistory()`. A UI rebuilt from saved history doesn’t show past rejected exchanges. See [Conversation History & Session Persistence](https://vaadin.com/docs/next/flow/ai-support/conversation-history.md).

## <a id="replacing-the-message-and-attachments"></a>Replacing the Message and Attachments

`setUserMessage()` replaces the text sent to the LLM. The replacement is also what appears in the Message List and the conversation history — the original text isn’t shown or recorded anywhere. A blank replacement drops the prompt; prefer `reject()` to cancel explicitly.

`setAttachments()` replaces the attachments the same way, both in the request and in the Message List. Pass an empty list to strip all attachments. Attachments with unsupported MIME types don’t reach the model: they still appear in the Message List, but the LLM provider leaves their content out of the request (see [File Attachments](https://vaadin.com/docs/next/flow/ai-support/file-attachments.md)). An interceptor is the place to convert such a file into something the model accepts:

```java
AIOrchestrator orchestrator = AIOrchestrator
        .builder(provider, systemPrompt)
        .withMessageList(messageList)
        .withInput(messageInput)
        .withFileReceiver(uploadManager)
        .withRequestInterceptor(event -> {
            List<AIAttachment> converted = event.getAttachments()
                    .stream()
                    .map(attachment -> isSpreadsheet(attachment)
                            ? new AIAttachment(
                                    attachment.name() + ".csv",
                                    "text/csv",
                                    toCsv(attachment.data()))
                            : attachment)
                    .toList();
            event.setAttachments(converted);
        })
        .build();
```

Here, spreadsheet uploads — a format LLMs don’t take as-is — are converted to CSV text. `isSpreadsheet` and `toCsv` are placeholders for application code.

> **Note: Pending Uploads Are Consumed Either Way**
>
> By the time the interceptor runs, the orchestrator has already taken pending uploads out of a configured file receiver. If the prompt is then rejected, dropped, or fails, those attachments are gone — they are not resubmitted with the next prompt. The interceptor’s event is the last place to see them.

## <a id="asynchronous-preprocessing"></a>Asynchronous Preprocessing

Some preprocessing can’t finish while the UI thread waits — a call to a remote moderation service, or a heavy media conversion. For these, `event.postpone(timeout)` suspends the prompt and returns a `RequestContinuation`. Run the work on the application’s own threads, and complete the continuation when done. `proceed()` resumes the prompt with the event’s content at that moment, and `fail(cause)` abandons it, reporting the cause as an error.

```java
AIOrchestrator orchestrator = AIOrchestrator
        .builder(provider, systemPrompt)
        .withMessageList(messageList)
        .withInput(messageInput)
        .withRequestInterceptor(event -> {
            var continuation = event.postpone(Duration.ofSeconds(10));
            var ui = UI.getCurrent();
            progressBar.setVisible(true);
            moderationService.checkAsync(event.getUserMessage())
                    .whenComplete((allowed, error) -> {
                        ui.access(() -> progressBar.setVisible(false));
                        if (error != null) {
                            continuation.fail(error);
                            return;
                        }
                        if (!allowed) {
                            event.reject("Please rephrase your message.");
                        }
                        continuation.proceed();
                    });
        })
        .build();
```

The event stays usable after `postpone()` returns. Content changes and rejections made afterwards take effect when the prompt resumes; make them on the thread that completes the continuation, before completing it — a change attempted after completion throws `IllegalStateException`. Completion is safe from any thread and first-wins: once the prompt has proceeded, failed, or timed out, later completions are ignored.

While a prompt is postponed, the UI shows nothing and further prompts are ignored. Give the user a sign that something is happening. The example makes an indeterminate `ProgressBar` visible before scheduling the work and hides it again on completion. The completion arrives on whichever thread runs the work, so wrap component changes in `ui.access()` with a `UI` reference captured in the interceptor.

Server push or polling must be enabled — with `@Push` on the application shell class (see [Server Push](https://vaadin.com/docs/next/flow/ai-support.md#server-push)) or with `UI.setPollInterval()` — for the resumed turn to reach the browser without user interaction.

The timeout is required. When it elapses before the continuation is completed, the prompt fails with a `TimeoutException`, reported the same way as `fail()`. The prompt’s content, including attachment data, stays referenced until the continuation completes or the timeout fires, so keep the timeout as tight as the work allows. If the UI the prompt was submitted from is detached before completion — the user closed the tab — the prompt is abandoned.

## <a id="errors"></a>Errors

An exception thrown from the interceptor aborts the prompt much like a rejection: nothing is sent or shown. The difference is that the exception counts as a failure — the `ResponseListener` and `AIController.onResponse()` receive it, and it propagates to the caller of the `prompt()` entry point. Throw only for real failures. For expected validation outcomes, use `reject()`.

Failures after postponing — `fail(cause)` or the timeout — reach the same listeners, but can’t propagate to the prompt caller, which returned long ago. Throwing from the interceptor after `postpone()` aborts the prompt like any other interceptor failure, and the continuation becomes inert.

## <a id="session-persistence"></a>Session Persistence

The interceptor is serialized with the orchestrator and, unlike the LLM provider, needs no `reconnect()` step. A lambda implementation must therefore capture only serializable state. Don’t capture a non-serializable service such as a moderation client in the lambda. Look it up when the interceptor runs instead, for example through a static accessor or an application-scoped registry. In an application that serializes sessions, the moderation service captured in the [earlier example](#asynchronous-preprocessing) would need this treatment.

A prompt that is postponed when the session is serialized doesn’t survive. Completing its continuation afterwards has no effect, and the deserialized orchestrator accepts new prompts once reconnected. See [Conversation History & Session Persistence](https://vaadin.com/docs/next/flow/ai-support/conversation-history.md) for the reconnection flow.
