diff --git a/entrypoints/internal.d.mts b/entrypoints/internal.d.mts
new file mode 100644
index 000000000..753b780e5
--- /dev/null
+++ b/entrypoints/internal.d.mts
@@ -0,0 +1,7 @@
+import type {StateChangeEvent} from '../types/state-change-events.d';
+
+export type Event = StateChangeEvent;
+
+export type ObservedRun = {
+	events: AsyncIterableIterator<Event>;
+};
diff --git a/lib/api-event-iterator.js b/lib/api-event-iterator.js
new file mode 100644
index 000000000..1b2b55bf1
--- /dev/null
+++ b/lib/api-event-iterator.js
@@ -0,0 +1,12 @@
+export async function * asyncEventIteratorFromApi(api) {
+	// TODO: support multiple runs (watch mode)
+	const {value: plan} = await api.events('run').next();
+
+	for await (const stateChange of plan.status.events('stateChange')) {
+		yield stateChange;
+
+		if (stateChange.type === 'end' || stateChange.type === 'interrupt') {
+			break;
+		}
+	}
+}
diff --git a/lib/cli.js b/lib/cli.js
index 309f45ba5..0e7afdfa6 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -8,6 +8,7 @@ import figures from 'figures';
 import yargs from 'yargs';
 import {hideBin} from 'yargs/helpers'; // eslint-disable-line n/file-extension-in-import
 
+import {asyncEventIteratorFromApi} from './api-event-iterator.js';
 import Api from './api.js';
 import {chalk} from './chalk.js';
 import validateEnvironmentVariables from './environment-variables.js';
@@ -470,6 +471,12 @@ export default async function loadCli() { // eslint-disable-line complexity
 		});
 	}
 
+	if (combined.observeRun && experiments.observeRunsFromConfig) {
+		combined.observeRun({
+			events: asyncEventIteratorFromApi(api),
+		});
+	}
+
 	api.on('run', plan => {
 		reporter.startRun(plan);
 
diff --git a/lib/load-config.js b/lib/load-config.js
index 102520f16..73c1af3f0 100644
--- a/lib/load-config.js
+++ b/lib/load-config.js
@@ -8,7 +8,7 @@ import {packageConfig, packageJsonPath} from 'package-config';
 
 const NO_SUCH_FILE = Symbol('no ava.config.js file');
 const MISSING_DEFAULT_EXPORT = Symbol('missing default export');
-const EXPERIMENTS = new Set();
+const EXPERIMENTS = new Set(['observeRunsFromConfig']);
 
 const importConfig = async ({configFile, fileForErrorMessage}) => {
 	const {default: config = MISSING_DEFAULT_EXPORT} = await import(url.pathToFileURL(configFile));
diff --git a/package.json b/package.json
index 806cb1652..284b525a1 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,9 @@
 				"types": "./entrypoints/plugin.d.cts",
 				"default": "./entrypoints/plugin.cjs"
 			}
+		},
+		"./internal": {
+			"types": "./entrypoints/internal.d.mts"
 		}
 	},
 	"type": "module",
diff --git a/test/internal-events/fixtures/.gitignore b/test/internal-events/fixtures/.gitignore
new file mode 100644
index 000000000..1fe1da7f5
--- /dev/null
+++ b/test/internal-events/fixtures/.gitignore
@@ -0,0 +1 @@
+internal-events.json
diff --git a/test/internal-events/fixtures/ava.config.js b/test/internal-events/fixtures/ava.config.js
new file mode 100644
index 000000000..034b7d501
--- /dev/null
+++ b/test/internal-events/fixtures/ava.config.js
@@ -0,0 +1,19 @@
+import fs from 'node:fs/promises';
+
+const internalEvents = [];
+
+export default {
+	files: [
+		'test.js',
+	],
+	nonSemVerExperiments: {
+		observeRunsFromConfig: true,
+	},
+	async observeRun(run) {
+		for await (const event of run.events) {
+			internalEvents.push(event);
+		}
+
+		await fs.writeFile('internal-events.json', JSON.stringify(internalEvents));
+	},
+};
diff --git a/test/internal-events/fixtures/package.json b/test/internal-events/fixtures/package.json
new file mode 100644
index 000000000..bedb411a9
--- /dev/null
+++ b/test/internal-events/fixtures/package.json
@@ -0,0 +1,3 @@
+{
+	"type": "module"
+}
diff --git a/test/internal-events/fixtures/test.js b/test/internal-events/fixtures/test.js
new file mode 100644
index 000000000..0fd3dbd7e
--- /dev/null
+++ b/test/internal-events/fixtures/test.js
@@ -0,0 +1,5 @@
+import test from 'ava';
+
+test('placeholder', t => {
+	t.pass();
+});
diff --git a/test/internal-events/test.js b/test/internal-events/test.js
new file mode 100644
index 000000000..dbfb6e56c
--- /dev/null
+++ b/test/internal-events/test.js
@@ -0,0 +1,28 @@
+import fs from 'node:fs/promises';
+import {fileURLToPath} from 'node:url';
+
+import test from '@ava/test';
+
+import {fixture} from '../helpers/exec.js';
+
+test('internal events are emitted', async t => {
+	await fixture();
+
+	const result = JSON.parse(await fs.readFile(fileURLToPath(new URL('fixtures/internal-events.json', import.meta.url))));
+
+	t.like(result[0], {
+		type: 'starting',
+		testFile: fileURLToPath(new URL('fixtures/test.js', import.meta.url)),
+	});
+
+	const testPassedEvent = result.find(event => event.type === 'test-passed');
+	t.like(testPassedEvent, {
+		type: 'test-passed',
+		title: 'placeholder',
+		testFile: fileURLToPath(new URL('fixtures/test.js', import.meta.url)),
+	});
+
+	t.like(result.at(-1), {
+		type: 'end',
+	});
+});
diff --git a/types/state-change-events.d.cts b/types/state-change-events.d.cts
new file mode 100644
index 000000000..fe37f517c
--- /dev/null
+++ b/types/state-change-events.d.cts
@@ -0,0 +1,143 @@
+type ErrorSource = {
+	isDependency: boolean;
+	isWithinProject: boolean;
+	file: string;
+	line: number;
+};
+
+type SerializedErrorBase = {
+	message: string;
+	name: string;
+	originalError: unknown;
+	stack: string;
+};
+
+type AggregateSerializedError = SerializedErrorBase & {
+	type: 'aggregate';
+	errors: SerializedError[];
+};
+
+type NativeSerializedError = SerializedErrorBase & {
+	type: 'native';
+	source: ErrorSource | undefined;
+};
+
+type AvaSerializedError = SerializedErrorBase & {
+	type: 'ava';
+	assertion: string;
+	improperUsage: unknown | undefined;
+	formattedCause: unknown | undefined;
+	formattedDetails: unknown | unknown[];
+	source: ErrorSource | undefined;
+};
+
+type SerializedError = AggregateSerializedError | NativeSerializedError | AvaSerializedError;
+
+export type StateChangeEvent = {
+	type: 'starting';
+	testFile: string;
+} | {
+	type: 'stats';
+	stats: {
+		byFile: Map<string, {
+			declaredTests: number;
+			failedHooks: number;
+			failedTests: number;
+			internalErrors: number;
+			remainingTests: number;
+			passedKnownFailingTests: number;
+			passedTests: number;
+			selectedTests: number;
+			selectingLines: boolean;
+			skippedTests: number;
+			todoTests: number;
+			uncaughtExceptions: number;
+			unhandledRejections: number;
+		}>;
+		declaredTests: number;
+		failedHooks: number;
+		failedTests: number;
+		failedWorkers: number;
+		files: number;
+		parallelRuns: {
+			currentIndex: number;
+			totalRuns: number;
+		} | undefined;
+		finishedWorkers: number;
+		internalErrors: number;
+		remainingTests: number;
+		passedKnownFailingTests: number;
+		passedTests: number;
+		selectedTests: number;
+		sharedWorkerErrors: number;
+		skippedTests: number;
+		timedOutTests: number;
+		timeouts: number;
+		todoTests: number;
+		uncaughtExceptions: number;
+		unhandledRejections: number;
+	};
+} | {
+	type: 'declared-test';
+	title: string;
+	knownFailing: boolean;
+	todo: boolean;
+	testFile: string;
+} | {
+	type: 'selected-test';
+	title: string;
+	knownFailing: boolean;
+	skip: boolean;
+	todo: boolean;
+	testFile: string;
+} | {
+	type: 'test-register-log-reference';
+	title: string;
+	logs: string[];
+	testFile: string;
+} | {
+	type: 'test-passed';
+	title: string;
+	duration: number;
+	knownFailing: boolean;
+	logs: string[];
+	testFile: string;
+} | {
+	type: 'test-failed';
+	title: string;
+	err: SerializedError;
+	duration: number;
+	knownFailing: boolean;
+	logs: string[];
+	testFile: string;
+} | {
+	type: 'worker-finished';
+	forcedExit: boolean;
+	testFile: string;
+} | {
+	type: 'worker-failed';
+	nonZeroExitCode?: boolean;
+	signal?: string;
+	err?: SerializedError;
+} | {
+	type: 'touched-files';
+	files: {
+		changedFiles: string[];
+		temporaryFiles: string[];
+	};
+} | {
+	type: 'worker-stdout';
+	chunk: Uint8Array;
+	testFile: string;
+} | {
+	type: 'worker-stderr';
+	chunk: Uint8Array;
+	testFile: string;
+} | {
+	type: 'timeout';
+	period: number;
+	pendingTests: Map<string, Set<string>>;
+}
+| {
+	type: 'end';
+};