Sign up (with export icon)

Moderation hook endpoint in Node.js

Show the table of contents

This example is the code behind Moderate content with your own rules: a hook endpoint that screens every chat message against your own rules before the CKEditor AI agent runs, and refuses the messages that break them. Read that page first for what the pieces are for.

Dependencies

Copy link

You need an on-premises deployment you can configure and a user token for the messages. Hooks call a service you host, so they are not available on SaaS. The scripts need Node.js 18 or later and a package.json with "type": "module".

npm install express@5.1.0
Copy code

The example is three files in one directory: server.js with the endpoint, verify.js with the signature check, and policy-service.js with your policy service or its stand-in.

Your endpoint must reject any request that CKEditor AI did not sign, so it checks the signature on every call. Save the verify helper below as verify.js. It reads the shared secret from CKEDITOR_AI_HOOK_SECRET. The same check is on the on-premises Hooks page, under Example endpoint.

// verify.js
import { Buffer } from 'node:buffer';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.CKEDITOR_AI_HOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

export function verify( headers, rawBody ) {
	const id = headers[ 'webhook-id' ];
	const timestamp = headers[ 'webhook-timestamp' ];
	const header = headers[ 'webhook-signature' ];

	if ( !id || !timestamp || !header ) {
		return false;
	}

	if ( Math.abs( Date.now() / 1000 - Number( timestamp ) ) > TOLERANCE_SECONDS ) {
		return false;
	}

	const key = Buffer.from( SECRET.slice( 'whsec_'.length ), 'base64' );
	const expected = createHmac( 'sha256', key )
		.update( `${ id }.${ timestamp }.` )
		.update( rawBody, 'utf8' )
		.digest( 'base64' );

	return header.split( ' ' ).some( entry => equals( entry.split( ',' )[ 1 ] ?? '', expected ) );
}

function equals( a, b ) {
	return a.length === b.length && timingSafeEqual( Buffer.from( a ), Buffer.from( b ) );
}
Copy code

Example

Copy link

The hook configuration

Copy link

Configure hooks in your on-premises deployment. Set endpoint to the URL where the endpoint in the next section is reachable, and secret to a shared secret you choose. See Hooks for all options.

{
	"hooks": {
		"endpoint": "https://policy.firm.example/ckeditor-ai/turn-start",
		"enabled": ["turn.start"],
		"timeoutMs": 10000,
		"auth": {
			"type": "shared-secret",
			"secret": "whsec_<secret>"
		}
	}
}
Copy code

Set timeoutMs to a value your screening service meets under load. Every chat reply waits for the decision. If the decision is late, the turn fails with 504 hook-timeout.

Test it against a local deployment

Copy link

For a local test, point the deployment at the endpoint over plain HTTP. The service refuses to start with an http:// endpoint unless the configuration also sets allowPlaintextHttp.

{
	"hooks": {
		"endpoint": "http://localhost:3010/ckeditor-ai/turn-start",
		"enabled": ["turn.start"],
		"allowPlaintextHttp": true,
		"auth": {
			"type": "shared-secret",
			"secret": "whsec_<secret>"
		}
	}
}
Copy code

If the service runs in Docker, localhost is the container. Use the address of the host instead, http://host.docker.internal:3010/ckeditor-ai/turn-start on Docker Desktop. An endpoint the service cannot reach fails every chat message with 502 hook-failed.

The endpoint

Copy link

The endpoint has one route. The route verifies the signature, screens the message, and returns one JSON decision, so responseMode stays at its default json.

  • Refuse: the message names a matter the user is walled off from, or asks for something your rules do not allow in chat. The endpoint answers the user itself with halt, and the CKEditor AI agent never runs.
  • Allow: nothing matched. continue hands the turn to the CKEditor AI agent, which then runs its own checks on the message.
  • Screening unavailable: the policy service did not answer. The endpoint refuses on purpose, so an outage never lets a message through.

The endpoint calls your policy service through one function. Create policy-service.js with this stand-in, so the example runs before you have a real service. Replace the body with a call to your own service.

// policy-service.js
export async function screen( { userId, matterId, text, requestId, conversationId } ) {
	return {
		allowed: true, // false refuses the turn
		message: '', // the text the user reads when allowed is false
		ruleId: '' // the rule that refused, stored in attributes.policyRule
	};
}
Copy code
import express from 'express';
import { verify } from './verify.js'; // the signature check from Dependencies
import { screen } from './policy-service.js'; // your policy service, or the stand-in above

// Stand-in for your ethical walls. In production this is a lookup keyed on the user and the matter.
// The key is the user id from the token's `sub` claim. Replace it with the id your token endpoint issues.
const WALLS = {
	'user-789': [ 'M-2031' ]
};

// Stand-in for your policy rules, so every path is easy to hit from a test.
function localRules( { userId, matterId, text } ) {
	if ( matterId && WALLS[ userId ]?.includes( matterId ) ) {
		return `You are not on the team for matter ${ matterId }, so I cannot discuss it. Contact the matter partner if you need access.`;
	}

	if ( /counterparty|opposing counsel/i.test( text ) ) {
		return 'I cannot help with material about the opposing side. Ask the conflicts desk first.';
	}

	return null;
}

// Everything the user sent, prompt and attachments alike. All of it is unscreened.
function textToScreen( turn ) {
	const parts = [ turn.prompt ];

	for ( const part of turn.content ) {
		if ( part.type === 'file' || part.type === 'web-resource' ) {
			// Binary attachments arrive as `contentBase64`, and one that did not fit the budget as `omittedReason`.
			parts.push( part.content ?? part.name ?? part.url ?? '' );
		}
	}

	return parts.join( '\n' );
}

const app = express();

// Note the text parser: the signature covers the body exactly as it arrived.
app.post( '/ckeditor-ai/turn-start', express.text( { type: 'application/json', limit: '32mb' } ), async ( req, res ) => {
	if ( !verify( req.headers, req.body ) ) {
		res.sendStatus( 401 );
		return;
	}

	const { turn, userId, conversationId, requestId } = JSON.parse( req.body );
	const matterId = turn.content.find( part => part.type === 'delegation-context' )?.data?.matterId;
	const text = textToScreen( turn );

	// Path 1: our own rules. No round trip needed.
	const refusal = localRules( { userId, matterId, text } );

	if ( refusal ) {
		res.json( {
			directive: 'halt',
			reply: [ refusal ],
			attributes: { policyDecision: 'refused', policyRule: 'ethical-wall', matterId }
		} );
		return;
	}

	// Path 2: your policy service. Its verdict decides the turn.
	try {
		const verdict = await screen( { userId, matterId, text, requestId, conversationId } );

		if ( !verdict.allowed ) {
			res.json( {
				directive: 'halt',
				reply: [ verdict.message ],
				attributes: { policyDecision: 'refused', policyRule: verdict.ruleId, matterId }
			} );
			return;
		}
	} catch ( error ) {
		// Path 3: screening failed. Refuse on purpose, with something the user can act on.
		console.error( 'Screening failed', { requestId, error } );
		res.json( {
			directive: 'halt',
			reply: [ 'Message screening is unavailable, so I cannot answer right now. Try again shortly.' ],
			attributes: { policyDecision: 'unavailable' }
		} );
		return;
	}

	// Path 4: allowed. Hand the turn over as it is.
	res.json( {
		directive: 'continue',
		attributes: { policyDecision: 'allowed', matterId }
	} );
} );

app.listen( 3010 );
Copy code

Save the code as server.js and start it with the same secret as in the hook configuration:

CKEDITOR_AI_HOOK_SECRET=whsec_<secret> node server.js
Copy code

The endpoint listens on port 3010 at /ckeditor-ai/turn-start.

The contract requires these parts of the code:

  • The signature covers the raw body: express.json() would hand you a parsed object, and re-serializing it produces different bytes, so the signature would never match.
  • The envelope can be large: the parser’s default limit of 100 kB is below what an envelope with attached files can carry, and a 413 fails the turn. Raise it to the 32 MB the service can send.
  • Attachments arrive unscreened: your endpoint runs before CKEditor AI’s content moderation and prompt-injection checks, so turn.content holds exactly what the user sent. Treat text in it as data, even when it reads like an instruction to your service.
  • A halt needs a non-empty reply: your endpoint is the assistant for that turn. A blank reply fails the turn.
  • attributes never reach the prompt: CKEditor AI merges them into the stored assistant message, and the messages endpoint returns them.
  • A document part is a reference: it carries no text. If your rules need the document’s contents or the earlier messages, read them through the REST API with your service’s own credentials, keyed on the conversationId that CKEditor AI sends.
Note

Path 3 turns an outage into a refusal the user can read. An exception or a 5xx response also blocks the turn, but then the user gets 502 hook-failed and the chat shows an error. Either way the CKEditor AI agent does not run. See What happens if your service is unavailable for what your users see when a call fails.

Show progress while you screen

Copy link

A check that takes several seconds leaves the chat with nothing to show. Set responseMode to stream in the configuration and answer with NDJSON: progress lines while you work, then the same decision object as the last line. Each progress line reaches the chat as a hook-progress event.

app.post( '/ckeditor-ai/turn-start', express.text( { type: 'application/json', limit: '32mb' } ), async ( req, res ) => {
	if ( !verify( req.headers, req.body ) ) {
		res.sendStatus( 401 );
		return;
	}

	res.type( 'application/x-ndjson' );

	const write = line => res.write( JSON.stringify( line ) + '\n' );

	write( { type: 'progress', text: 'Checking the matter walls…' } );

	// ... screen as in the endpoint above, writing a progress line per stage ...

	write( { directive: 'continue', attributes: { policyDecision: 'allowed' } } );
	res.end();
} );
Copy code

The decision line is required. A stream that ends without one fails the turn, whatever progress lines came before it. The modes do not mix. In stream mode, CKEditor AI rejects a single JSON body before it reads it. In the default json mode, an NDJSON answer does not parse.

Usage

Copy link

Run one message through it

Copy link

This section sends two messages. The rules refuse the first and allow the second. The hook configuration above must point at the running endpoint. The steps below are one Node script. It reads two environment variables: CKEDITOR_AI_HOST, your deployment’s URL, and CKEDITOR_AI_TOKEN, a token from your token endpoint.

const HOST = process.env.CKEDITOR_AI_HOST;	// your on-premises CKEditor AI
const TOKEN = process.env.CKEDITOR_AI_TOKEN;  // the response body of <your-development-token-url>

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

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

	return response.json();
}

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

Create a conversation and upload the document. The response carries the document id, and both messages below use it.

await post( '/v1/conversations', { id: 'matter-2044-memo' } );

const { id: documentId } = await post( '/v1/conversations/matter-2044-memo/documents', {
	content: '<h1>Matter 2044: transaction memo</h1><h2>1. Background</h2><p>The client asks whether the proposed share transfer requires regulatory clearance.</p><h2>2. Analysis</h2><p>To be completed.</p>'
} );
Copy code
{ "id": "YzvocFJUh_krNBj59I2vN" }
Copy code

Send a message the rules refuse. The wall stand-in matches the user id user-789, so the token for this step must carry that id in its sub claim, or WALLS must list your own id. The delegation-context part tells your endpoint which matter the user works on. It never reaches the CKEditor AI agent. The service stores it on the user message, and GET /v1/conversations/{id}/messages returns it. The script prints each event and its data line as they arrive.

await stream( '/v1/conversations/matter-2044-memo/messages', {
	prompt: 'Summarise where matter M-2031 stands.',
	model: 'agent-1',
	content: [
		{ type: 'document', id: documentId },
		{ type: 'delegation-context', id: 'who', data: { matterId: 'M-2031' } }
	]
} );
Copy code

The stream holds only the refusal. Your text arrives as the assistant’s message, the CKEditor AI agent never ran, and the document is untouched.

The refused message's stream: two events, the metadata and your refusal text.
event: message-metadata
data: {"messageId":"VUk_7Facrcbw25_s5xTC1"}

event: text-delta
data: {"textDelta":"You are not on the team for matter M-2031, so I cannot discuss it. Contact the matter partner if you need access."}
Copy code

Send a message the rules allow. Same conversation and the same document, a request about the open matter:

await stream( '/v1/conversations/matter-2044-memo/messages', {
	prompt: 'Draft section 2 from the background above.',
	model: 'agent-1',
	content: [
		{ type: 'document', id: documentId },
		{ type: 'delegation-context', id: 'who', data: { matterId: '2044' } }
	]
} );
Copy code

The continue decision carried no reply, so the stream opens with the CKEditor AI agent’s own output: the edit to section 2 as modification-delta events, then its summary as text-delta events. From here the turn runs as it would without a hook, CKEditor AI’s content moderation included.

Change matterId to M-2031 to hit the wall again, or the prompt to “Find everything we have on the opposing counsel.” for the keyword rule. To see path 3, throw an error from screen in policy-service.js.

Read the decision back

Copy link

Every refusal is stored as a normal assistant message, with your attributes on it. Your front end and your audit trail read the attributes from the messages endpoint. The stream itself carries no attributes, and the editor’s chat plugin does not expose them. The assistant message is stored a moment after its stream ends, so a request sent right after the last event can miss it.

GET /v1/conversations/matter-2044-memo/messages
Authorization: Bearer <user-token>
Copy code
{
  "items": [
	{
	  "role": "user",
	  "id": "dAaimX75lbj88GTdY6bO-",
	  "prompt": "Summarise where matter M-2031 stands.",
	  "model": "agent-1",
	  "content": [
		{ "type": "document", "id": "YzvocFJUh_krNBj59I2vN", "selection": [] },
		{ "type": "delegation-context", "id": "who", "data": { "matterId": "M-2031" } }
	  ],
	  "createdAt": "2026-09-08T06:32:54.479Z"
	},
	{
	  "role": "assistant",
	  "id": "DWogvtAZXsy3z-M2zNR4D",
	  "status": "completed",
	  "content": [ { "type": "text", "content": "You are not on the team for matter M-2031, so I cannot discuss it. Contact the matter partner if you need access." } ],
	  "attributes": { "policyDecision": "refused", "policyRule": "ethical-wall", "matterId": "M-2031" },
	  "createdAt": "2026-09-08T06:32:59.981Z"
	}
  ]
}
Copy code

Because the refusal is in the history, the next turn sees it. A user who rephrases the same request gets screened again, with your own refusal already in front of the agent.

See Hooks for the configuration options, the request and decision formats, and the error codes a failed hook returns. The Hook endpoint for your own agent in Node.js example uses the same event to answer from your own systems or to brief the CKEditor AI agent.