Sign up (with export icon)

Style guide context in Node.js

Show the table of contents

This example is the code behind Check drafts against your style guide. Read that page first for what the pieces are for.

A scientific publisher wants authors to check medical manuscripts against its journal’s style guide before they submit. A context stores the journal’s house style. CKEditor AI applies the context to every conversation automatically, and a custom review references the context to check spelling, grammar, and style on a manuscript.

Dependencies

Copy link

The page is one Node.js 18 or newer script with no packages. It uses top-level await, so save it with the .mjs extension or set "type": "module" in package.json. You need a token with ai:admin for the admin requests and an author’s token with the scopes under Grant the permission. Set the three environment variables below, and save the sample manuscript from Run it as manuscript.html next to the script.

Each create request returns the id of the new object, and each PATCH returns the updated object. The page shows only the responses it uses again.

import { readFile } from 'node:fs/promises';

const HOST = process.env.CKEDITOR_AI_HOST;				// https://ai.cke-cs.com, https://ai.cke-cs-eu.com, or your on-premises CKEditor AI
const ADMIN_TOKEN = process.env.CKEDITOR_AI_ADMIN_TOKEN;  // a token with ai:admin
const TOKEN = process.env.CKEDITOR_AI_TOKEN;			  // an author's token

async function request( method, path, body, token ) {
	const headers = { 'Authorization': `Bearer ${ token }` };
	const init = { method, headers };

	if ( body instanceof FormData ) {
		init.body = body;
	} else if ( body !== undefined ) {
		headers[ 'Content-Type' ] = 'application/json';
		init.body = JSON.stringify( body );
	}

	const response = await fetch( `${ HOST }${ path }`, init );

	if ( !response.ok ) {
		throw new Error( `${ response.status } ${ await response.text() }` );
	}

	return response.json();
}

const post = ( path, body, token ) => request( 'POST', path, body, token );

async function stream( path, body ) {
	const response = await fetch( `${ HOST }${ path }`, {
		method: 'POST',
		headers: { 'Authorization': `Bearer ${ TOKEN }`, 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
		body: JSON.stringify( body )
	} );

	if ( !response.ok ) {
		throw new Error( `${ response.status } ${ await response.text() }` );
	}

	const decoder = new TextDecoder();
	let buffer = '';

	for await ( const chunk of response.body ) {
		buffer += decoder.decode( chunk, { stream: true } );

		let end;
		while ( ( end = buffer.indexOf( '\n\n' ) ) >= 0 ) {
			const lines = buffer.slice( 0, end ).split( '\n' );
			buffer = buffer.slice( end + 2 );

			const event = lines.find( line => line.startsWith( 'event: ' ) )?.slice( 7 );
			const data = lines.find( line => line.startsWith( 'data: ' ) )?.slice( 6 );

			console.log( `event: ${ event }\ndata: ${ data }\n` );
		}
	}
}
Copy code

Example

Copy link

Create the context

Copy link

You choose the id, and the review references that id, so do not change it after you create the context. You cannot reuse the id of a deleted context in the same environment.

await post( '/v1/admin/contexts', {
	id: 'house-style',
	name: 'Journal house style',
	description: 'The writing rules, terminology, and protected content every published manuscript follows.'
}, ADMIN_TOKEN );
Copy code

Add the rules

Copy link

Add three prompts, one for each concern, so you can edit each set of rules on its own. The third one, the protected content, stops the review from correcting a term that is already right and from changing a clinical claim.

await post( '/v1/admin/contexts/house-style/prompts', {
	name: 'Writing rules',
	content: 'Titles and headings use sentence case. Write "participants", never "subjects" or "patients", for people enrolled in a trial. Put a space between a value and its unit (20 mg, 12 weeks), except before %. Expand every abbreviation at first use, with the abbreviation in parentheses. Report results in the past tense.'
}, ADMIN_TOKEN );

const terminology = await post( '/v1/admin/contexts/house-style/prompts', {
	name: 'Terminology',
	content: 'Drug names: use the international nonproprietary name ("atorvastatin", never "Lipitor"). Write "COVID-19" and "SARS-CoV-2" exactly so. Use British spelling: "randomised", "enrolment", "haemoglobin". Write "healthcare" as one word.'
}, ADMIN_TOKEN );

await post( '/v1/admin/contexts/house-style/prompts', {
	name: 'Protected content',
	content: 'Never change the meaning of a sentence. Keep every number, unit, dose, statistical value, and hedge ("may", "suggests", "was associated with") exactly as written. Do not correct medical, scientific, or statistical terms, gene and protein names, drug names, or Latin phrases, even when they look like spelling errors. Leave equations, table cells, citation markers, and reference entries untouched. In figure captions, correct spelling and grammar but keep the wording. Where a sentence is correct, leave it as it is; do not rephrase for preference. Correct grammar without restructuring the author\'s sentence when the structure is already correct.'
}, ADMIN_TOKEN );
Copy code

Each call returns the id of the new prompt. The script keeps the terminology prompt’s id in terminology.id, and Change a rule edits that prompt in place.

{ "id": "EkuHRNDQlaYwicJHIkv4a" }
Copy code

Apply it to every conversation

Copy link

Set autoApply on the context. A conversation then receives all three prompts if its token carries the ai:contexts:house-style scope, whether it starts in the editor plugin’s chat or in your own interface. Grant the permission covers the scope.

await request( 'PATCH', '/v1/admin/contexts/house-style', {
	autoApply: {
		features: [ 'conversations' ]
	}
}, ADMIN_TOKEN );
Copy code

Grant the permission

Copy link

Both paths need ai:contexts:house-style in the author’s token. An auto-applied context reaches a conversation only when the token covers it, and the review under Run it references the context explicitly. Add the scope to the permissions your token endpoint already issues. See Authentication for the endpoint and Permissions for the scopes.

{
  "auth": {
	"ai": {
	  "permissions": [
		"ai:conversations:read",
		"ai:conversations:write",
		"ai:models:agent",
		"ai:reviews:custom",
		"ai:contexts:house-style"
	  ]
	}
  }
}
Copy code

Usage

Copy link

Run it

Copy link

The sample manuscript is synthetic. Each kind of content the review has to handle appears once: a title and an abstract with the style and grammar errors of an author who writes in English as an additional language, a results paragraph full of correct specialist terms, an equation, a table, a figure with a caption, a paragraph that is already right, and a reference list. Save it as manuscript.html. Every top-level element carries a data-id, which the review needs to anchor its suggestions.

Keep every equation in its own element. If an equation shares a paragraph with a sentence that needs a correction, the rewritten textDelta can break the LaTeX escapes. The sample puts the equation in e4b, on its own.

<h2 data-id="e1">Effect Of Lipitor On Cholesterol In Older Subjects: A Randomised Trial</h2>
<p data-id="e2">In this study we have randomised total of 240 subject aged 65 or older for receiving Lipitor 20mg daily or the placebo during 12 weeks.</p>
<p data-id="e3">LDL-C fell by 38% in the Lipitor group and by 2% with placebo (p<0.001). Apolipoprotein B and non-HDL cholesterol decreased similarly. Subjects with familial hypercholesterolaemia may benefit less, and this finding should be interpreted with caution.</p>
<p data-id="e4">The percentage change were calculated for each participants as</p>
<p data-id="e4b"><span class="math-tex">\(\Delta = \frac{LDL_{12} - LDL_{0}}{LDL_{0}} \times 100\)</span></p>
<table data-id="e5">
	<thead>
		<tr><th>Outcome</th><th>Atorvastatin (n = 120)</th><th>Placebo (n = 120)</th></tr>
	</thead>
	<tbody>
		<tr><td>LDL-C change</td><td>−38%</td><td>−2%</td></tr>
		<tr><td>Discontinued treatment</td><td>7</td><td>9</td></tr>
	</tbody>
</table>
<figure data-id="e6">
	<img src="figure-1.png" alt="Line chart of mean LDL-C by study visit">
	<figcaption>Figure 1. Mean LDL-C by visit. Error bars shows 95% confidence intervals.</figcaption>
</figure>
<p data-id="e7">The trial was registered before enrolment of the first participant. Atorvastatin was supplied by the hospital pharmacy.</p>
<ol data-id="e8">
	<li>Ortega L, Brennan K, Sato M. Statin adherence in adults over 65: a cohort study. J Geriatr Cardiol. 2021;18:412-20.</li>
	<li>Nkemelu A, Haddad R. Lipid targets in secondary prevention. Eur Heart J. 2019;40:1180-8.</li>
</ol>
Copy code

First, a chat with no style instructions: create a conversation, upload the manuscript, and ask for a tighter abstract. The message says nothing about style.

await post( '/v1/conversations', { id: 'atorvastatin-manuscript' }, TOKEN );

const manuscript = await readFile( 'manuscript.html', 'utf8' );
const document = await post( '/v1/conversations/atorvastatin-manuscript/documents', { content: manuscript }, TOKEN );

await stream( '/v1/conversations/atorvastatin-manuscript/messages', {
	prompt: 'Tighten the abstract.',
	model: 'agent-1',
	content: [ { type: 'document', id: document.id } ]
} );
Copy code

The document upload returns the id the message refers to:

{ "id": "cGgA3P1wYcfgkJupTkWma" }
Copy code

The response is a stream of modification-delta events, and <!-- existing document --> marks the parts the model did not change. The message asked only for a tighter abstract. The auto-applied context replaced the brand name, changed “subjects” to “participants”, and put the space in “20 mg”. The chat also corrected grammar outside the abstract, because the chat edits the whole document.

The chat's stream: the edited abstract as modification-delta events.
event: message-metadata
data: {"messageId":"fvQreOnvdqMXIw11u5M6J"}

event: modification-delta
data: {"id":"HLGB1B-ZqOEoJMLeYUhty","documentId":"cGgA3P1wYcfgkJupTkWma","textDelta":"<!-- existing document --><p data-id=\"e2\">We randomised 240 participants aged 65 or older to receive atorvastatin 20 mg daily or placebo for 12 weeks.</p><p data-id=\"e3\">Low-density lipoprotein cholesterol (LDL-C) fell by 38% with atorvastatin and by 2% with placebo (p<0.001). Apolipoprotein B and non-high-density lipoprotein (non-HDL) cholesterol decreased similarly. Participants with familial hypercholesterolaemia may benefit less, and this finding should be interpreted with caution.</p><p data-id=\"e4\">The percentage change was calculated for each participant as</p><!-- existing document --><figure data-id=\"e6\"><img src=\"figure-1.png\" alt=\"Line chart of mean LDL-C by study visit\" ><figcaption>Figure 1. Mean LDL-C by visit. Error bars show 95% confidence intervals.</figcaption></figure><!-- existing document -->"}

event: conversation-title
data: {"conversationTitle":"Editing and Tightening the Abstract"}
Copy code

For controlled, per-element suggestions, use the review below.

The review: the request carries the whole manuscript, a one-line prompt that names the job, and the context that supplies the rules.

await stream( '/v1/reviews/custom/calls', {
	content: [ { type: 'text', content: manuscript } ],
	prompt: 'Correct spelling, grammar, and punctuation, and apply the house style. Suggest a change only where a sentence contains an error or breaks a rule.',
	model: 'agent-1',
	contexts: [ { type: 'context', id: 'house-style' } ]
} );
Copy code

One event per element, nine in total. Five carry a suggestion, and four return "operation": "unmodified".

The review's stream: nine review-delta events, one per element.
event: review-metadata
data: {"callId":"WBkUOiIms32quxGFErf0O","pendingElements":9}

event: review-delta
data: {"dataId":"e1","operation":"edit","textDelta":"<h2 data-id=\"e1\">Effect of atorvastatin on cholesterol in older participants: A randomised trial</h2>"}

event: review-delta
data: {"dataId":"e2","operation":"edit","textDelta":"<p data-id=\"e2\">In this study, we randomised a total of 240 participants aged 65 or older to receive atorvastatin 20 mg daily or placebo for 12 weeks.</p>"}

event: review-delta
data: {"dataId":"e3","operation":"edit","textDelta":"<p data-id=\"e3\">Low-density lipoprotein cholesterol (LDL-C) fell by 38% in the atorvastatin group and by 2% with placebo (p<0.001). Apolipoprotein B and non-high-density lipoprotein (non-HDL) cholesterol decreased similarly. Participants with familial hypercholesterolaemia may benefit less, and this finding should be interpreted with caution.</p>"}

event: review-delta
data: {"dataId":"e4","operation":"edit","textDelta":"<p data-id=\"e4\">The percentage change was calculated for each participant as</p>"}

event: review-delta
data: {"dataId":"e4b","operation":"unmodified"}

event: review-delta
data: {"dataId":"e5","operation":"unmodified"}

event: review-delta
data: {"dataId":"e6","operation":"edit","textDelta":"<figure data-id=\"e6\">\n\t<img src=\"figure-1.png\" alt=\"Line chart of mean LDL-C by study visit\">\n\t<figcaption>Figure 1. Mean LDL-C by visit. Error bars show 95% confidence intervals.</figcaption>\n</figure>"}

event: review-delta
data: {"dataId":"e7","operation":"unmodified"}

event: review-delta
data: {"dataId":"e8","operation":"unmodified"}
Copy code

The output meets the requirements the protected-content prompt sets:

  • Grammar is corrected, structure is kept: the abstract sentence gets its articles, agreement, and prepositions fixed. Its structure stays as the author wrote it, unlike the chat’s tightened version above.
  • Specialist terms are unchanged: “Apolipoprotein B”, “familial hypercholesterolaemia”, and “LDL-C” stay as written. The only change to them is the expansion the house style requires at first use.
  • Meaning is preserved: “38%”, “p<0.001”, the 20 mg dose, and the hedge “may benefit less” keep their values and wording.
  • Structured content is predictable: the equation, the table, and the reference list return unmodified because the rules protect them. The caption gets its grammar fix and keeps its wording.
  • Nothing is suggested where nothing is wrong: the registration paragraph returns unmodified.

A verdict on a correct sentence is the least stable part of a review. On some runs, a clean paragraph returns an unwanted edit. Rerun the review before you treat one result as the rule.

In CKEditor 5, the editor converts each review-delta into a track-changes suggestion on that element, and the author accepts or rejects each one. See What comes back in the response.

Change a rule

Copy link

The managing editor changes the journal to US spelling. One request edits the terminology prompt in place, and the next conversation and the next review write “randomized”. You deploy nothing.

await request( 'PATCH', `/v1/admin/contexts/house-style/prompts/${ terminology.id }`, {
	content: 'Drug names: use the international nonproprietary name ("atorvastatin", never "Lipitor"). Write "COVID-19" and "SARS-CoV-2" exactly so. Use US spelling: "randomized", "enrollment", "hemoglobin". Write "healthcare" as one word.'
}, ADMIN_TOKEN );
Copy code

The response is the updated prompt:

{
	"id": "EkuHRNDQlaYwicJHIkv4a",
	"name": "Terminology",
	"content": "Drug names: use the international nonproprietary name (\"atorvastatin\", never \"Lipitor\"). Write \"COVID-19\" and \"SARS-CoV-2\" exactly so. Use US spelling: \"randomized\", \"enrollment\", \"hemoglobin\". Write \"healthcare\" as one word.",
	"createdAt": "2026-09-08T06:27:29.258Z",
	"updatedAt": "2026-09-08T06:29:15.394Z"
}
Copy code

To see what the context holds, list its prompts with the admin token. The response is a page with items, previousCursor, and nextCursor. Each item carries the prompt’s id, name, and content, newest first.

const prompts = await request( 'GET', '/v1/admin/contexts/house-style/prompts', undefined, ADMIN_TOKEN );
Copy code

Rerun the review on manuscript.html after every rule change and read the whole output.

Add the full terminology list

Copy link

The terminology prompt holds a handful of terms. The journal’s approved list runs to hundreds of entries, and a prompt of that length uses part of the model’s context window in every conversation. Put the list in a second context as a file and reference that context from the review only. Save your approved terms as glossary.md next to the script. Any structure works, because the model reads the file as text.

await post( '/v1/admin/contexts', {
	id: 'journal-glossary',
	name: 'Journal terminology list',
	description: 'Approved terms, abbreviations, and drug names. Referenced by the review only.'
}, ADMIN_TOKEN );

const form = new FormData();
form.append( 'file', new Blob( [ await readFile( 'glossary.md' ) ] ), 'glossary.md' );

await post( '/v1/admin/contexts/journal-glossary/files', form, ADMIN_TOKEN );
Copy code

The multipart field is file, and the file name comes from the part’s filename, which FormData takes from the third argument of append. An optional attributes field takes a JSON-encoded object. The endpoint accepts Markdown, plain text, HTML, PDF, DOCX, PNG, and JPEG. The response carries the file id:

{ "id": "2wQ4RZ2oWuJre062IzLRo" }
Copy code

Add ai:contexts:journal-glossary to the token from Grant the permission and a second entry to the review request’s contexts. Conversations do not reference the glossary, so the chat is unchanged.

await stream( '/v1/reviews/custom/calls', {
	content: [ { type: 'text', content: manuscript } ],
	prompt: 'Correct spelling, grammar, and punctuation, and apply the house style. Suggest a change only where a sentence contains an error or breaks a rule.',
	model: 'agent-1',
	contexts: [
		{ type: 'context', id: 'house-style' },
		{ type: 'context', id: 'journal-glossary' }
	]
} );
Copy code

You now have a house-style context that every conversation applies automatically, a glossary context that only the review reads, and a review that returns one suggestion per element. To change a rule, edit its prompt as under Change a rule. See Context library for the other ways to fill a context, and Reviews for the review contract.