Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/stable-composer-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hashintel/petrinaut": patch
---

Add a generic host-rendered AI composer control with stable finalized-text submission,
conversation identity, stop handling, schema-validated interactive-tool text mapping, and an
explicit separate-message target for corrections.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ADR-0009: OpenAI voice uses the app-owned UI turn shell

Date: 2026-08-26
Status: accepted for the bounded H-6763 preview stack
Extends: [ADR-0004](0004-in-petrinaut-staging-and-the-monorepo-import.md), which keeps Brunch and
Petrinaut composition in applications and reusable libraries mutually unaware
Preserves: [ADR-0003](0003-three-register-ir.md), which makes Brunch's capture fold authoritative,
and the [Petrinaut integration attach contract](../specs/petrinaut-integration.md#attach-contract)
Decided on: `kostandin/h-6763-petrinaut-composer-api`, from the approved H-6763 OpenAI voice plan

## Context

H-6763 adds spoken input and output to a Brunch elicitation shown in Petrinaut. The existing
production boundary already has the semantics the feature needs: one AI SDK UI-message stream,
the correlated `brunch_ask` tool, and a principal-owned conversation. A voice implementation can
either preserve that boundary or create a second conversation authority in the audio provider.
Only the first choice preserves Brunch's durable history, captures, pending asks, completion, and
projection contracts.

The first rollout is a disabled preview. The production contracts for authenticated ownership,
distributed quotas, telemetry, replay, and final Petrinaut projection do not all exist yet. The
preview therefore needs a boundary that permits input and output experiments without claiming
production recovery or public availability.

## Decision

1. **The host application owns voice.** `apps/petrinaut-website` owns OpenAI policy, WebRTC,
transcript event parsing, half-duplex state, playback, feature UI, and server routes.
`@hashintel/petrinaut` exposes only a generic composer control and finalized-text submission
seam. Brunch packages contain no provider code.
2. **OpenAI is the only runtime voice provider.** Do not add a provider abstraction, selector,
compatibility layer, or ElevenLabs dependency, configuration, route, script, test, or
diagnostic.
3. **Realtime is transcription-only.** The server atomically combines browser SDP with trusted
transcription policy and calls OpenAI's unified WebRTC initialization endpoint. The fixed
model is `gpt-live-transcribe`. Semantic VAD begins at low eagerness as a tunable evaluation
setting. Provider keys, prompts, vocabulary, language policy, and model selection remain
server-side. Realtime never generates assistant responses.
4. **Completed provider items are the only admitted audio input.** Partials are display-only.
Completed items are keyed by connection epoch, provider item ID, and content index, then enter
the existing Petrinaut composer and AI SDK transport once. A pending `brunch_ask` uses its
existing correlated tool-output path; otherwise the final becomes a stable-ID user message.
A user-initiated correction is a new explicit message and opts out of pending-tool mapping
rather than being silently inferred from its wording.
5. **The interaction is half-duplex.** The microphone is closed while Brunch is handling a turn
and while speech is synthesized or playing. Barge-in and simultaneous listening and playback
are out of scope.
6. **Speech receives canonical Brunch text exactly.** A dedicated OpenAI Speech request receives
only finalized assistant text or the validated `brunch_ask.input.question` selected from the
AI SDK message structure. The application does not scrape rendered DOM, ask Realtime to "say
exactly," or ask a model to rewrite the text. Failure leaves the same text visible.
7. **The preview fails closed and is disabled by default.** Voice is unavailable when server
policy, credentials, or the Brunch transport are unavailable. Text chat remains available.
Public production remains disabled until FE-1439, FE-1420, platform authentication,
distributed quotas, FE-1505 telemetry, and FE-1438/FE-1440 completion and projection contracts
are available and consumed.

## Consequences

- Petrinaut's public API gains a generic host control with stable `submitText`, `stop`, messages,
status, and the effective host-supplied or generated conversation identity. Interactive tools may
opt into a schema-validated text-to-output mapper. Keyboard and alternate finalized text therefore
cannot bypass ask correlation by default; the host may explicitly target a separate message for
a correction.
- OpenAI implementation names and policy stay in `apps/petrinaut-website`. The existing
`transport-aisdk` package remains the sole browser-to-Brunch conversation transport.
- Preview PRs may demonstrate transcription and exact canonical speech before production
recovery exists, but they may not claim durable redelivery, authenticated access, distributed
rate enforcement, production telemetry, or final projection.
- A later provider choice, full duplex, sentence-level streaming speech, mobile support, or
acoustic-pronunciation guarantee requires a new decision. Exact lexical input to Speech is the
enforceable fidelity contract in this record.

## Revisit condition

Revisit if the unified OpenAI WebRTC initialization API cannot enforce server-owned transcription
policy; if the generic composer seam cannot preserve the existing AI SDK and `brunch_ask` paths;
or if production authentication, replay, telemetry, or projection contracts require a boundary
change rather than an application adapter. Do not address any of these by making browser or
provider history authoritative.
42 changes: 36 additions & 6 deletions libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
`recommendation-demo-vehicle.md` as the September staging plan · **Evidence base**: the
Petrinaut survey (FE-1358, `research/petrinaut-survey.md`), re-verified against
`hashintel/hash` source on 2026-08-18 · **Amended**: FE-1506 (stable UI and voice attach
contract).
contract), H-6763 / ADR-0009 (generic composer submission and app-owned voice boundary).

## Problem Statement

Expand Down Expand Up @@ -33,7 +33,7 @@ stream chunks and knows nothing about Flue.

## Seams

One primary seam, three supporting ones — all existing except the brunch server's front door,
One primary seam, four supporting ones — all existing except the brunch server's front door,
which the design needs anyway:

1. **The ChatTransport wire seam** (primary; the contract-test surface): the AI SDK
Expand All @@ -45,6 +45,13 @@ which the design needs anyway:
3. **The storage port seam** (ADR-0002 N5): the owner key is tested as store-level refusals.
4. **The artifact seam** (`parseSDCPNFile` / `sdcpnFileSchema`): unchanged; net validity
checked in CI through the pure parser.
5. **The generic composer seam**: a host may render a control beside Petrinaut's text composer and
receive stable `submitText` and `stop` callbacks plus the effective AI SDK conversation identity
and state. Finalized alternate text uses the same AI SDK `useChat` instance as keyboard input.
When exactly one unresolved interactive tool registers a schema-validated text mapper,
`submitText` completes that tool; otherwise it submits a stable-ID user message. Ambiguous mapped
tools are refused. A host may explicitly target an ordinary message for a correction that must
not answer the pending tool.

## Attach Contract

Expand All @@ -59,10 +66,16 @@ The panel and the voice edge attach to Brunch through one stable surface:
`x-brunch-principal` header. The current UI shell keeps that value in localStorage so it is
stable across reloads; replacing the local UID with authenticated identity must preserve the
same request-level ownership semantics.
4. **Composer submission**: Petrinaut accepts an optional stable conversation ID and host composer
control, then exposes the effective host-supplied or generated identity to that control. Keyboard
and alternate finalized text both enter the same `submitText` function. A pending `brunch_ask`
is answered only through the existing correlated tool-output path; text is not silently
downgraded to an ordinary user message when more than one mapped ask is pending. Explicit
corrections target new messages rather than silently mutating or answering another pending ask.

These three parts change only with notice to the panel and voice-edge owners. A provider-specific
voice requirement does not silently alter this surface; it arrives as a generic UI-shell extension
or triggers an explicit contract revision.
These four parts change only with notice to the panel and voice-edge owners. A provider-specific
voice requirement does not silently alter this surface; provider code and policy remain in the
host application under ADR-0009, while reusable Petrinaut and Brunch packages stay provider-free.

## User Stories

Expand Down Expand Up @@ -112,6 +125,8 @@ or triggers an explicit contract revision.
21. As a future petrinaut-website maintainer, I want brunch-specific wiring contained at the
app level (as the existing Actual-mode brunch-demo route already is), so that removing or
evolving it never archaeology-digs through the library.
22. As a Petrinaut host, I want finalized alternate input to share keyboard submission and pending
interactive-tool correlation, so that a host control cannot create a second conversation path.

## Implementation Decisions

Expand Down Expand Up @@ -160,6 +175,20 @@ or triggers an explicit contract revision.
is a generic host-supplied-handlers extension to the `aiAssistant` prop (post-import,
per ADR-0004's boundary discipline).

**Generic composer control**

- `@hashintel/petrinaut` accepts an optional conversation ID and host render callback. The callback
receives the effective host-supplied or generated AI SDK conversation identity, current messages
and status, plus stable `submitText` and `stop` functions.
- A host interactive tool may define `fromComposerText({ input, text })`. Petrinaut parses the
pending input, invokes the mapper, and parses its output before submitting the correlated tool
result. Unknown or unmapped tools preserve ordinary message submission; multiple eligible tools
fail visibly rather than guessing. The host may explicitly target a separate message for a
correction or follow-up that must not resolve a pending tool.
- The seam is provider- and elicitor-agnostic. OpenAI WebRTC, transcription policy, speech, and
half-duplex state belong to `apps/petrinaut-website`; Brunch remains behind the existing
transport. See [ADR-0009](../adr/0009-openai-voice-ui-turn-shell.md).

**Identity and storage**

- The principal is ui-shell-owned: the demo site mints a random UID into localStorage and
Expand Down Expand Up @@ -207,7 +236,8 @@ or triggers an explicit contract revision.

## Out of Scope

- Voice (conditional nice-to-have per FE-1359's tiers; unchanged by the pivot).
- Provider-specific voice behavior in Petrinaut or Brunch. The app-owned, disabled H-6763 preview
is governed by ADR-0009; production recovery and rollout wait for its named prerequisites.
- HASH-app integration (design-for via the principal and adapter abstractions; no build).
- The interpretation-render panel's visual design and placement (app-level UI vs.
`PetrinautSlots` — decided when the demo-site wiring starts, after the spikes).
Expand Down
8 changes: 8 additions & 0 deletions libs/@hashintel/petrinaut/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @hashintel/petrinaut

## Unreleased

### Patch Changes

- Add a generic host-rendered AI composer control with stable finalized-text submission,
conversation identity, stop handling, schema-validated interactive-tool text mapping, and an
explicit separate-message target for corrections.

## 0.0.19

### Patch Changes
Expand Down
12 changes: 11 additions & 1 deletion libs/@hashintel/petrinaut/docs/ai-assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ While a response is streaming you can:
- Press **Stop AI response** (the send button turns into a stop icon) to halt the current response.
- Type your next message in the composer -- it is queued for after the current response ends.

The application embedding Petrinaut may place an additional control beside the message box. For
example, a host can offer another way to enter finalized text. Text submitted by that control
behaves like text sent with the keyboard: it joins the same conversation and, when an inline
question is waiting for an answer, completes that question rather than starting an unrelated
message. A host can explicitly submit a separate message instead when the text is a correction or
other follow-up that must not answer the pending question.

**Clear AI chat** via the delete button in the top right of the panel: wipes the conversation, stops any in-flight stream, and tells the host app to forget the messages (if the host persists them).

## What the assistant can do
Expand Down Expand Up @@ -56,4 +63,7 @@ When the assistant edits code surfaces (lambdas, kernels, dynamics, visualizers,

## Host configuration

Whether the assistant is available, where the conversation is stored (in-memory, in your host app's database, or anywhere else), and the model behind it are all controlled by the host application that embeds Petrinaut. Read-only documents and the simulate-mode restrictions described above always apply when applicable.
Whether the assistant is available, which additional composer controls appear, where the
conversation is stored (in-memory, in your host app's database, or anywhere else), and the model
behind it are all controlled by the host application that embeds Petrinaut. Read-only documents
and the simulate-mode restrictions described above always apply when applicable.
6 changes: 6 additions & 0 deletions libs/@hashintel/petrinaut/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export { PetrinautOptimizationContext } from "./react/optimization-context";

export type { PetrinautSlots } from "./ui/types/petrinaut-slots";
export type { ViewportAction } from "./ui/types/viewport-action";
export type {
PetrinautAiComposerControl,
PetrinautAiComposerControlContext,
PetrinautAiComposerStatus,
PetrinautAiComposerSubmitTextResult,
} from "./ui/types/ai-assistant-composer-control";
export type {
PetrinautNavigationAction,
PetrinautNavigationController,
Expand Down
6 changes: 6 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export type {
PetrinautAiChatTransport,
PetrinautProps,
} from "./petrinaut";
export type {
PetrinautAiComposerControl,
PetrinautAiComposerControlContext,
PetrinautAiComposerStatus,
PetrinautAiComposerSubmitTextResult,
} from "./types/ai-assistant-composer-control";
export type {
PetrinautNavigationAction,
PetrinautNavigationController,
Expand Down
5 changes: 5 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/petrinaut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const editorRootStyle = css({
backgroundColor: "neutral.s25",
});

import type { PetrinautAiComposerControl } from "./types/ai-assistant-composer-control";
import type { PetrinautAiInteractiveTool } from "./types/ai-interactive-tool";
import type {
PetrinautAiMessage,
Expand All @@ -38,11 +39,15 @@ import type {
export type PetrinautAiChatTransport = PetrinautAiTransport;

export type PetrinautAiAssistant = {
/** Optional host-owned identity; `useChat` generates one when omitted. */
conversationId?: string;
/** Host-owned dynamic tools that render inline in the AI conversation. */
interactiveTools?: readonly PetrinautAiInteractiveTool[];
messages?: PetrinautAiMessage[];
onClearMessages?: () => void;
onMessages?: (messages: PetrinautAiMessage[]) => void;
/** Render a host-owned control inside the assistant composer. */
renderComposerControl?: PetrinautAiComposerControl;
transport: PetrinautAiTransport;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { PetrinautAiMessage } from "../views/Editor/panels/ai-assistant-panel/types";
import type { ReactNode } from "react";

/** Current lifecycle state of Petrinaut's AI SDK conversation. */
export type PetrinautAiComposerStatus =
| "submitted"
| "streaming"
| "ready"
| "error";

/** Outcome from submitting finalized text through the assistant composer. */
export type PetrinautAiComposerSubmitTextResult =
| { kind: "message"; messageId: string }
| { kind: "interactive-tool"; toolCallId: string };

/** Stable controls and current conversation state supplied to a host control. */
export type PetrinautAiComposerControlContext = {
/** Effective AI SDK identity, whether host-supplied or generated by `useChat`. */
conversationId: string;
messages: PetrinautAiMessage[];
status: PetrinautAiComposerStatus;
/** Call from an event handler or effect, never while rendering. */
stop: () => Promise<void>;
/** Call from an event handler or effect, never while rendering. */
submitText: (params: {
id?: string;
/** Defaults to `auto`; use `message` for text that must not answer a pending tool. */
target?: "auto" | "message";
text: string;
}) => Promise<PetrinautAiComposerSubmitTextResult>;
};

/** Render callback for a host-owned control inside the assistant composer. */
export type PetrinautAiComposerControl = (
context: PetrinautAiComposerControlContext,
) => ReactNode;
40 changes: 30 additions & 10 deletions libs/@hashintel/petrinaut/src/ui/types/ai-interactive-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export type PetrinautAiInteractiveToolDefinition<Input, Output> = {
inputSchema: PetrinautAiInteractiveToolSchema<Input>;
/** Runtime contract for the widget's submitted output. */
outputSchema: PetrinautAiInteractiveToolSchema<Output>;
/**
* Optionally map text submitted through the assistant composer to this
* tool's output. Petrinaut validates both the pending input and mapped
* output before completing the tool call.
*/
fromComposerText?: (params: { input: Input; text: string }) => Output;
/** Inline component shown while awaiting input and after submission. */
component: ComponentType<
PetrinautAiInteractiveToolWidgetProps<Input, Output>
Expand All @@ -49,6 +55,7 @@ type ErasedInteractiveToolDefinition = {
toolName: string;
parseInput: (value: unknown) => unknown;
parseOutput: (value: unknown) => unknown;
fromComposerText?: (params: { input: unknown; text: string }) => unknown;
component: ComponentType<
PetrinautAiInteractiveToolWidgetProps<unknown, unknown>
>;
Expand All @@ -68,17 +75,30 @@ export type PetrinautAiInteractiveTool = {
*/
export const definePetrinautAiInteractiveTool = <Input, Output>(
definition: PetrinautAiInteractiveToolDefinition<Input, Output>,
): PetrinautAiInteractiveTool => ({
toolName: definition.toolName,
[interactiveToolDefinition]: {
): PetrinautAiInteractiveTool => {
const fromComposerText = definition.fromComposerText;

return {
toolName: definition.toolName,
parseInput: (value) => definition.inputSchema.parse(value),
parseOutput: (value) => definition.outputSchema.parse(value),
component: definition.component as ComponentType<
PetrinautAiInteractiveToolWidgetProps<unknown, unknown>
>,
},
});
[interactiveToolDefinition]: {
toolName: definition.toolName,
parseInput: (value) => definition.inputSchema.parse(value),
parseOutput: (value) => definition.outputSchema.parse(value),
fromComposerText: fromComposerText
? ({ input, text }) =>
definition.outputSchema.parse(
fromComposerText({
input: definition.inputSchema.parse(input),
text,
}),
)
: undefined,
component: definition.component as ComponentType<
PetrinautAiInteractiveToolWidgetProps<unknown, unknown>
>,
},
};
};

/** @internal */
export const getPetrinautAiInteractiveToolDefinition = (
Expand Down
Loading
Loading