---
title: Kernel API
description: Reference for the c15t consent kernel in JavaScript, covering how
  to get or create one, its snapshot and subscriptions, commands, setters,
  hydration, events and listener ordering.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Get a kernel

The kernel holds consent state and runs commands. Every JavaScript setup has
one:

* `consent.kernel`, from `init()` in `@c15t/browser`.
* `runtime.kernel`, from `createConsentRuntime` in `c15t/runtime`.
* A kernel you create yourself, as shown [below](#create-a-kernel-yourself).

The kernel alone supplies state and commands. It renders nothing, loads no
script and reads no storage; the modules around it do that.

```ts
const unsubscribe = kernel.subscribe((snapshot) => {
	console.log(snapshot.resolution.status, snapshot.effectivePermissions);
});
```

This fragment assumes one of the kernels above. Keep `unsubscribe` and call it
during teardown.

## Read state

|Member|Returns|What it does|
|--|--|--|
|`getSnapshot()`|`ConsentSnapshot`|The current state, frozen. Cheap to call. See the [snapshot reference](/docs/frameworks/javascript/api/snapshot).|
|`subscribe(listener)`|`() => void`|Call `listener` with each new snapshot.|
|`getServerSnapshot()`|`ConsentSnapshot`|The first snapshot, as a server render saw it. Render from this during hydration, then switch to `getSnapshot()`.|
|`refresh(now?)`|`ConsentSnapshot`|Evaluate again at `now`, so an expired choice cannot linger behind a timer. Returns the new snapshot.|

A snapshot keeps the same object for a field whose value did not change, so
`previous.effectivePermissions === next.effectivePermissions` tells you
permissions did not change.

## Choose commands by intent

Commands are asynchronous and are the only way to record anything.

|Command|Returns|Effect|
|--|--|--|
|`commands.init()`|`Promise<{ ok, error? }>`|Resolve the policy through the transport. Failed attempts retry in the background.|
|`commands.save('all')`|`Promise<SaveResult>`|Record acceptance of every category in scope.|
|`commands.save('none')`|`Promise<SaveResult>`|Record rejection of every optional category in scope.|
|`commands.save({ marketing: false })`|`Promise<SaveResult>`|Record only the categories you name.|
|`commands.save()`|`Promise<SaveResult>`|Record the staged draft, falling back to the current choice or the policy's defaults for each category.|
|`commands.dismissNotice()`|`Promise<NoticeDismissResult>`|Record that the visitor saw a notice. Grants nothing. Only works while `promptRequirement.kind` is `notice`.|
|`commands.identify(user)`|`Promise<void>`|Link the record to `{ externalId, identityProvider?, identityToken? }` and load the server's record for that subject. The [token](/docs/self-host/api/node-sdk#verify-a-link-made-in-the-browser) verifies the link.|

`save(input, context)` takes a second argument. `context.categories` narrows
`'all'`, `'none'` or an empty input to the categories your UI displays, which
is how `@c15t/browser` limits Accept All to the dialog's categories.
`context.vendors` records vendor-level grants.

A save updates permissions and notifies subscribers before the backend
request starts. `SaveResult.ok` reports the request. When it fails, the choice
stays in effect and the payload is queued for retry.
[Consent state](/docs/concepts/consent-state#when-a-choice-is-saved)
documents the ordering.

## Change inputs and UI state

`set` operations run synchronously and notify subscribers. None of them
records a choice.

|Setter|What it changes|
|--|--|
|`set.activeUI('banner' \|'dialog' \|'none')`|The surface your UI should show.|
|`set.draft(values)`|Staged category values that a no-argument `save()` confirms. Never a grant.|
|`set.overrides({ country, region, language, gpc })`|Decision inputs, merged into the current ones; `undefined` clears a key. Call `commands.init()` after it to resolve the policy again.|
|`set.language(code)`|The language override.|
|`set.privacySignals({ gpc })`|The detected GPC signal. c15t reads the browser's signal itself; use this to pass one detected elsewhere, such as a `Sec-GPC` request header.|
|`set.consentCategories(categories)`|The configured categories. Discovered categories stay.|
|`set.registerConsentCategories(categories)`|Add categories your integrations use. They stay for the kernel's lifetime.|
|`set.vendors(patch, options?)`|Merge declared vendors.|
|`set.subjectId(id)`|The subject ID.|
|`set.iab(patch)`|Written by the IAB module. Do not call it yourself.|

## Apply stored records

Pass validated records as `initialRecords` to `createConsentKernel()`, such
as a choice read from a cookie with `readStoredRecordsFromCookieHeader()`.
They apply without counting as a visitor action. In the browser,
[`createPersistence()`](/docs/frameworks/javascript/modules/persistence)
reads storage into the kernel on mount and keeps tabs in step afterwards.

## Events

`kernel.events.on(type, listener)` returns an unsubscribe function. Each
listener receives the event object.

|Event|Fields|When|
|--|--|--|
|`init:applied`|`snapshot`|A policy resolved, from the transport or a resolved prefetch.|
|`init:failed`|`error`, `attempt`, `nextRetryMs`|Initialization failed. `nextRetryMs` is `null` when no retry remains.|
|`choice:recorded`|`snapshot`, `confirmed`, `actionAt`|An accept, reject or save recorded categories.|
|`permissions:changed`|`snapshot`, `previous`|Effective permissions changed value, for any reason.|
|`notice:dismissed`|`snapshot`, `dismissal`|A notice was acknowledged.|
|`vendors:recorded`|`snapshot`, `actionAt`|A save changed which vendors are denied.|
|`vendors:set`|`snapshot`|The declared vendor list changed.|
|`overrides:set`|`snapshot`|Decision inputs changed.|
|`user:identified`|`snapshot`|`identify` linked a user.|
|`subject:resolved`|`snapshot`|A save received the server's subject ID.|
|`iab:set`|`snapshot`|The IAB state changed.|
|`records:cleared`|none|Stored records were cleared.|
|`save:replayed`|`subjectId`, `ok`, `rejected?`|A queued save was sent again. `rejected` holds the backend's refusal code.|
|`command:init:started`, `command:save:started`|none|A command began.|
|`command:init:completed`, `command:save:completed`|`result`|A command finished.|
|`command:error`|`command`, `error`|A command failed.|

Use `choice:recorded` to react to a visitor's decision and
`permissions:changed` to start and stop code. [Callbacks](/docs/frameworks/javascript/callbacks)
explains the difference.

## How listeners are notified

`kernel.subscribe()` and `kernel.events.on()` listeners run synchronously when
a change commits, in registration order. Each receives the frozen snapshot that
change produced.

* A listener that throws does not stop the listeners after it or fail the
  command that caused the change. In a browser page c15t passes the error to
  `reportError`. Everywhere else, including Bun and Deno servers where
  `reportError` would end the process, it logs the error with `console.error`.
* A listener can call commands and setters. Before the nested call returns,
  every other listener receives the change in progress and then the new one.
  No listener misses a change, such as a denial that another listener
  immediately reverses, or receives changes out of order.
* A listener added during a notification first receives the next change. A
  listener removed during a notification is not called again.
* Listeners that keep changing consent in response to each other are stopped
  after 100 nested notifications, and the error is reported. Notifications
  already queued still arrive, so other listeners end on the current snapshot.
  A listener that changes consent after that point is not called again.

## Create a kernel yourself

Most apps should use `createConsentRuntime` or `@c15t/browser`, which connect
the modules for you. Create the kernel directly only when you need to choose
every module:

```ts title="src/consent.ts"
import { createConsentKernel, createHostedTransport } from 'c15t';
import { createPersistence } from 'c15t/modules/persistence';
import { createScriptLoader } from 'c15t/modules/script-loader';

import { scripts } from './scripts';

export const startConsentKernel = async function startConsentKernel(
	backendURL: string
) {
	const kernel = createConsentKernel({
		transport: createHostedTransport({ backendURL }),
	});
	// Each module is yours to create and dispose. This kernel has no iframe
	// blocker, network blocker, data clearing or reload after revocation.
	const persistence = createPersistence({ kernel });
	const loader = createScriptLoader({ kernel, scripts });
	await kernel.commands.init();
	return {
		dispose() {
			loader.dispose();
			persistence.dispose();
			kernel.dispose();
		},
		kernel,
	};
};
```

This kernel stores choices and loads scripts. It has no iframe blocker,
network blocker or data clearing, and it does not reload the page when a
visitor withdraws permission, so a vendor that already ran keeps running.
Add each one you need from its `c15t/modules/*` entry point, and dispose it
before the kernel:

|Module|Entry point|Page|
|--|--|--|
|Persistence|`c15t/modules/persistence`|[Persistence](/docs/frameworks/javascript/modules/persistence)|
|Script loader|`c15t/modules/script-loader`|[Script loader](/docs/frameworks/javascript/modules/script-loader)|
|Network blocker|`c15t/modules/network-blocker`|[Network blocker](/docs/frameworks/javascript/modules/network-blocker)|
|Iframe blocker|`c15t/modules/iframe-blocker`|[Iframe blocker](/docs/frameworks/javascript/modules/iframe-blocker)|
|Clear on revocation|`c15t/modules/clear-on-revocation`|[Clear on revocation](/docs/frameworks/javascript/clear-on-revocation)|
|Reload after withdrawal|`watchRevocationReload` from `c15t`|This page, below|

`watchRevocationReload({ kernel, isEnabled, getOnBeforeReload? })` reloads the
page after a save turns off something that was allowed. It returns a
disposer.

`createConsentKernel(config)` takes a `transport`, not a transport factory.
Use `createHostedTransport({ backendURL })` or `createOfflineTransport()`
from `c15t`, or your own object with `init` and `save`. Other useful config
fields:

|Field|What it does|
|--|--|
|`transport`|Carries `init`, `save`, `identify` and `loadSubjectRecord`. Without it, commands succeed and send nothing.|
|`consentCategories`|Categories to offer, within the policy's scope.|
|`initialOverrides`, `initialUser`|Decision inputs and an identified user at start.|
|`initialRecords`, `initialPolicyResolution`|Records and a policy a server already resolved.|
|`initialTranslations`|Copy to start with.|
|`translationOverrides`|Messages keyed by language, such as your `i18n.messages`. The kernel applies the entry for the active language over `initialTranslations` and over each init response, key by key.|
|`initRetry`|Retry for a failed `init`: `{ maxAttempts, baseDelayMs, maxDelayMs }`, or `false`. Defaults to 5 attempts from 1 second up to 30 seconds.|
|`now`|The evaluation clock at start, for a server render and its hydration to agree.|

Construction has no side effects, so a server can build a kernel and read its
snapshot.

## Check it works

1. Subscribe to the kernel and log `policyPending` and `resolution.status`.
   After `commands.init()`, `policyPending` is `false` and the status is
   `matched`.
2. Add an `events.on('choice:recorded', …)` listener. It logs once for each
   accept, reject or save, and not on reload.
3. Call `kernel.dispose()`. Background retries stop; `getSnapshot()` still
   works.
