diff --git a/.changeset/28444-thank-you-record-fields.md b/.changeset/28444-thank-you-record-fields.md
new file mode 100644
index 00000000000..fee5b2e54ea
--- /dev/null
+++ b/.changeset/28444-thank-you-record-fields.md
@@ -0,0 +1,18 @@
+---
+'@object-ui/console': minor
+---
+
+feat(console): a public form's thank-you panel shows the record field values its `{{record.field_name}}` tokens name
+
+Clause-②: no
+Metadata-protocol: semantic
+
+A `thank-you` panel's `title` and `message` may name an engine-generated field of the record just submitted (an `autonumber` field, such as a case number) as `{{record.field_name}}`. On the public form route the Console now replaces each such token with the value the submit answer carries for it under `fields` (`PublicFormSubmitResponseSchema`, `@objectstack/spec/api`), so a web-to-case visitor sees their case number instead of the token's own text:
+
+```ts
+submitBehavior: { kind: 'thank-you', message: 'Your case number is {{record.case_number}}.' }
+```
+
+- Which tokens a text holds is read by the spec's `thankYouRecordFieldNames`; the Console has no token grammar of its own.
+- A token the answer carries no value for shows as empty, never as the token's text.
+- A value is drawn as plain text, never as markup, and a filled value is never read for tokens again.
diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx
index d5f884cdec4..f477cc1707b 100644
--- a/content/docs/ui/forms.mdx
+++ b/content/docs/ui/forms.mdx
@@ -385,6 +385,12 @@ formViews: {
| `continue` | Re-read prefill values and reset state — user can submit another response without reload. |
| `next-record` | Stub for queue contexts; falls back to thank-you when no queue is wired. |
+On the public path, a `thank-you` panel's `title` and `message` may name the record just submitted as `{{record.field_name}}`, and the Console replaces each token with the value the submit answer carries for it, drawn as plain text; only an engine-generated field (an `autonumber` field, such as a case number) may be named, and a token with no value in the answer shows as empty:
+
+```ts
+submitBehavior: { kind: 'thank-you', title: 'Case opened', message: 'Your case number is {{record.case_number}}.' }
+```
+
### What a `redirect` `url` may be
`url` is **not** a free-form address. It was ruled on 2026-08-11 ([#7496](https://github.com/objectstack-ai/objectstack/issues/7496)) and the schema enforces it, so a URL outside this shape is a parse error at authoring time rather than a surprise in the browser:
diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx
index e152029976c..1459a19d3a0 100644
--- a/content/docs/ui/public-data-collection.mdx
+++ b/content/docs/ui/public-data-collection.mdx
@@ -35,6 +35,12 @@ formViews: {
The `submitBehavior` above sets the **copy**, not the behavior: `thank-you` is already the default on the public path, because an anonymous submitter may not read the record back. The internal path defaults the other way — a `type: 'form'` action lands the operator on the record it just created — so do not carry a public form's confirmation panel over to an internal one by habit. See [`submitBehavior` is mode-aware](/docs/ui/forms#the-default-is-mode-aware).
+The panel can also show the visitor a reference to the record they just created: name the field in `title` or `message` as `{{record.field_name}}`, and the Console fills it from the submit answer. Only an engine-generated field (an `autonumber` field, such as a case number) may be named; `os validate` and the publish gate refuse any other.
+
+```ts
+submitBehavior: { kind: 'thank-you', title: 'Thanks!', message: 'Your case number is {{record.case_number}}.' },
+```
+
That `sharing` block wires the anonymous endpoints automatically:
```
diff --git a/ui/apps/console/src/components/FormPage.thankYouRecordFields-28444.test.tsx b/ui/apps/console/src/components/FormPage.thankYouRecordFields-28444.test.tsx
new file mode 100644
index 00000000000..712123ff81e
--- /dev/null
+++ b/ui/apps/console/src/components/FormPage.thankYouRecordFields-28444.test.tsx
@@ -0,0 +1,148 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * A public form's `thank-you` panel fills its `{{record.field_name}}` tokens
+ * from the submit answer's `fields` (`PublicFormSubmitResponseSchema`): a
+ * web-to-case visitor sees their case number, never the token's own text. A
+ * name the answer carries no value for fills as empty, and a value is drawn as
+ * text, never as markup.
+ */
+
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { createI18n } from '@object-ui/i18n';
+import { FormPage, fillThankYouPanel, readThankYouAnswerFields } from './FormPage';
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+/** The public `/forms/:slug` resolver payload, with a `thank-you` panel. */
+function publicPayload(panel: { title?: string; message?: string }) {
+ return {
+ slug: 'report-issue',
+ object: 'support_case',
+ label: 'Report an issue',
+ form: {
+ type: 'simple',
+ sections: [{ fields: ['subject'] }],
+ submitBehavior: { kind: 'thank-you', ...panel },
+ },
+ objectSchema: {
+ name: 'support_case',
+ fields: {
+ subject: { type: 'text', label: 'Subject' },
+ case_number: { type: 'autonumber', label: 'Case number' },
+ },
+ },
+ };
+}
+
+function stubFetch(payload: unknown, answer: unknown) {
+ return vi.fn(async (url: string) => {
+ const body = String(url).includes('/forms/report-issue/submit') ? answer : payload;
+ return {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response;
+ });
+}
+
+async function submitPublic(panel: { title?: string; message?: string }, answer: unknown) {
+ vi.stubGlobal('fetch', stubFetch(publicPayload(panel), answer));
+ const view = render(
+
+
+ } />
+
+ ,
+ );
+ await screen.findByLabelText(/Subject/);
+ await userEvent.type(screen.getByLabelText(/Subject/), 'Printer jams');
+ await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
+ return view;
+}
+
+beforeEach(() => {
+ createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('public thank-you panel — record field tokens', () => {
+ it('shows the answer\'s value for a `{{record.case_number}}` token in the message and the title', async () => {
+ await submitPublic(
+ { title: 'Case {{record.case_number}} opened', message: 'Your case number is {{record.case_number}}.' },
+ { id: 'case-1', fields: { case_number: 'CASE-0042' } },
+ );
+
+ expect(await screen.findByText('Your case number is CASE-0042.')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Case CASE-0042 opened' })).toBeInTheDocument();
+ });
+
+ it('fills a token the answer carries no value for as empty, never as the token text', async () => {
+ const { container } = await submitPublic(
+ { message: 'Your case number is {{record.case_number}}.' },
+ { id: 'case-1' },
+ );
+
+ expect(await screen.findByText('Your case number is .')).toBeInTheDocument();
+ expect(container.textContent).not.toContain('{{');
+ });
+
+ it('draws a value as text, never as markup', async () => {
+ const value = '
bold';
+ const { container } = await submitPublic(
+ { message: 'Case: {{record.case_number}}' },
+ { id: 'case-1', fields: { case_number: value } },
+ );
+
+ expect(await screen.findByText(`Case: ${value}`)).toBeInTheDocument();
+ expect(container.querySelector('img')).toBeNull();
+ expect(container.querySelector('b')).toBeNull();
+ });
+
+ it('reads the answer through the spec\'s shape: an answer with keys beyond `id` and `fields` fills nothing', async () => {
+ await submitPublic(
+ { message: 'Your case number is {{record.case_number}}.' },
+ { id: 'case-1', fields: { case_number: 'CASE-0042' }, record: { case_number: 'CASE-0042' } },
+ );
+
+ await waitFor(() => expect(screen.getByText('Your case number is .')).toBeInTheDocument());
+ });
+});
+
+describe('fillThankYouPanel', () => {
+ it('never reads a filled value for tokens again', () => {
+ const filled = fillThankYouPanel(
+ { message: '{{record.case_number}} / {{record.ref_code}}' },
+ { case_number: '{{record.ref_code}}', ref_code: 'R-7' },
+ );
+ expect(filled.message).toBe('{{record.ref_code}} / R-7');
+ });
+
+ it('fills a name that is an Object.prototype key as empty', () => {
+ expect(fillThankYouPanel({ message: 'x{{record.constructor}}y' }, {}).message).toBe('xy');
+ });
+
+ it('leaves a panel with no token as authored', () => {
+ const panel = { title: 'Thanks', message: 'We got it.' };
+ expect(fillThankYouPanel(panel, { case_number: 'CASE-1' })).toEqual(panel);
+ });
+});
+
+describe('readThankYouAnswerFields', () => {
+ it('reads `fields` off the public submit answer', () => {
+ expect(readThankYouAnswerFields({ id: 'a', fields: { case_number: 7 } })).toEqual({ case_number: 7 });
+ });
+
+ it('reads none off an answer that is not the public submit shape', () => {
+ expect(readThankYouAnswerFields({ object: 'x', id: 'a', record: { case_number: 7 } })).toBeUndefined();
+ expect(readThankYouAnswerFields(undefined)).toBeUndefined();
+ });
+});
diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx
index 52a32cbcf08..a4ac3681315 100644
--- a/ui/apps/console/src/components/FormPage.tsx
+++ b/ui/apps/console/src/components/FormPage.tsx
@@ -169,6 +169,8 @@ import {
import { pickLocalized, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n';
import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from '@object-ui/app-shell';
import type { ObjectFormSection } from '@object-ui/types';
+import { PublicFormSubmitResponseSchema } from '@objectstack/spec/api';
+import { thankYouRecordFieldNames } from '@objectstack/spec/ui';
import { resolveSubmitRedirect } from './submitRedirect';
const API_BASE = (import.meta.env.VITE_SERVER_URL || '') + '/api/v1';
@@ -1185,6 +1187,58 @@ export function resolveSubmitBehavior(
return mode === 'internal' ? { kind: 'created-record' } : { kind: 'thank-you' };
}
+/** The values a public submit answer carries for its `thank-you` panel, keyed by field name. */
+export type ThankYouAnswerFields = Readonly>;
+
+/**
+ * The `fields` of a public submit answer (`PublicFormSubmitResponseSchema`,
+ * `@objectstack/spec/api`): the stored values of the engine-generated fields
+ * the form's `thank-you` panel names. An answer that is not that shape, or one
+ * without `fields`, carries none.
+ */
+export function readThankYouAnswerFields(answer: unknown): ThankYouAnswerFields | undefined {
+ const parsed = PublicFormSubmitResponseSchema.safeParse(answer);
+ return parsed.success ? parsed.data.fields : undefined;
+}
+
+/**
+ * A `thank-you` panel's `title` and `message` with each `{{record.field_name}}`
+ * token replaced by the answer's value for that field.
+ *
+ * Which tokens a text holds is `thankYouRecordFieldNames`' answer, the spec's
+ * one reading of the token, so this page carries no grammar of its own: each
+ * named field's token is its literal `{{record.` + name + `}}` spelling. A
+ * name the answer carries no value for fills as empty, never as the token's
+ * own text. A filled value is never read for tokens again, and the page draws
+ * the result as a React text child, so a value shows as text and never as
+ * markup.
+ */
+export function fillThankYouPanel(
+ panel: { title?: string; message?: string },
+ fields: ThankYouAnswerFields | undefined,
+): { title?: string; message?: string } {
+ const names = thankYouRecordFieldNames({ kind: 'thank-you', ...panel });
+ if (names.length === 0) return panel;
+ const fill = (text: string | undefined): string | undefined => {
+ if (text === undefined) return undefined;
+ // Literal runs still to be read for tokens, interleaved with filled values.
+ let parts: Array<{ text: string; filled: boolean }> = [{ text, filled: false }];
+ for (const name of names) {
+ const token = `{{record.${name}}}`;
+ const value = fields && Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : '';
+ parts = parts.flatMap((part) =>
+ part.filled
+ ? [part]
+ : part.text.split(token).flatMap((run, i) =>
+ i === 0 ? [{ text: run, filled: false }] : [{ text: value, filled: true }, { text: run, filled: false }],
+ ),
+ );
+ }
+ return parts.map((part) => part.text).join('');
+ };
+ return { title: fill(panel.title), message: fill(panel.message) };
+}
+
/**
* Strip the transport envelope off a data-API body, if there is one.
*
@@ -2474,6 +2528,11 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
/** The `error` banner's id, which a row the banner names points at (objectstack-ai/objectstack#27417). */
const errorBannerId = `form-error:${useId()}`;
const [submitted, setSubmitted] = useState(false);
+ /**
+ * The values the public submit answer carries for the `thank-you` panel's
+ * `{{record.field_name}}` tokens ({@link readThankYouAnswerFields}).
+ */
+ const [thankYouFields, setThankYouFields] = useState(undefined);
/**
* The outcome of a `redirect` submit behaviour: the in-app route to go to
* once `delayMs` has elapsed, or the fact that the authored destination was
@@ -2864,8 +2923,13 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
setCompletedSteps(new Set());
break;
}
- case 'next-record':
case 'thank-you':
+ // Only the public answer carries the panel's values: the internal
+ // create answers `CreateDataResponse`, which is not that shape.
+ if (mode === 'public') setThankYouFields(readThankYouAnswerFields(result));
+ setSubmitted(true);
+ break;
+ case 'next-record':
default:
setSubmitted(true);
break;
@@ -2954,8 +3018,10 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
// away instead. It carries no author-supplied title/message, so it renders
// the generic confirmation.
if (submitted && (behavior.kind === 'thank-you' || behavior.kind === 'created-record')) {
- const title = behavior.kind === 'thank-you' ? behavior.title : undefined;
- const message = behavior.kind === 'thank-you' ? behavior.message : undefined;
+ const { title, message } =
+ behavior.kind === 'thank-you'
+ ? fillThankYouPanel({ title: behavior.title, message: behavior.message }, thankYouFields)
+ : { title: undefined, message: undefined };
return (