Hook endpoint for your own agent in Node.js
This example is the code behind Extend your agent’s capabilities: a hook endpoint that runs your agent on every chat message, a context with your house style, and the requests that drive one message through the setup. Read that page first for what the pieces are for.
The endpoint below uses a keyword map to choose a path, so you can test every path. In a real integration, your agent makes that choice. Do not use the keyword map in production.
You need an on-premises deployment you can configure, a token with ai:admin for the context requests, and a user token for the messages. The user token needs ai:conversations:read, ai:conversations:write, ai:models:agent, and ai:contexts:firm-house-style. An auto-applied context reaches a conversation only when the token carries the scope of that context. The scripts need Node.js 18 or later and a package.json with "type": "module".
npm install express@5.1.0Copy codeThe endpoint uses a verify helper that checks the signature we send with every hook request. Save it as verify.js next to your server file. 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 codeCreate the context once through the admin API. The autoApply field in the last request applies it to every conversation, so the CKEditor AI agent follows these rules on every turn and your endpoint does not have to send them.
POST /v1/admin/contexts
Content-Type: application/json
Authorization: Bearer <admin-token>
{
"id": "firm-house-style",
"name": "House style for client deliverables",
"description": "How the firm writes: tone, terminology, and what every deliverable must and must not contain."
}Copy codePOST /v1/admin/contexts/firm-house-style/prompts
Content-Type: application/json
Authorization: Bearer <admin-token>
{
"name": "Editing rules",
"content": "Write in British English. Refer to the client as \"the Company\", never by name. Present findings as numbered observations with a risk rating. Do not change section headings or numbering. Never add legal or tax advice that the consultant did not ask for."
}Copy codePATCH /v1/admin/contexts/firm-house-style
Content-Type: application/json
Authorization: Bearer <admin-token>
{
"autoApply": {
"features": [ "conversations" ]
}
}Copy codeConfigure 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://agent.firm.example/ckeditor-ai/turn-start",
"enabled": ["turn.start"],
"timeoutMs": 60000,
"auth": {
"type": "shared-secret",
"secret": "whsec_<secret>"
}
}
}Copy codeFor 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 codeIf 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 has one route. The route verifies the signature, chooses one of three paths for the message, and answers with one JSON decision. Because the decision is a single JSON body, leave responseMode at its default json. If you set responseMode to "stream", the service expects application/x-ndjson. It rejects any other content type before reading the body and fails the turn with 502 hook-failed.
- Start a workflow: the message asks for a back-office job. The endpoint starts it, answers the chat itself with
halt, and puts the workflow id inattributesfor the front end. - Enrich: the message needs your data. The endpoint fetches the data and answers
continue, with the data inforward.contextand a rule inforward.instructions. - Pass through: the message needs nothing from you. The endpoint answers
continuewith noforward.
import express from 'express';
import { verify } from './verify.js'; // the signature check from Dependencies
// Stand-in for your agent: a keyword map instead of a model, so every path is easy to hit from a test.
function classify( prompt ) {
if ( /independence check/i.test( prompt ) ) {
return { kind: 'start-workflow', workflow: 'independence check' };
}
if ( /findings/i.test( prompt ) ) {
return { kind: 'enrich' };
}
return { kind: 'pass-through' };
}
// Stand-in for the deal database. In production this is a query keyed on the engagement id.
const FINDINGS = `
## Deal review findings, engagement 4711
1. Revenue for FY2025 includes EUR 1.2m recognised before delivery. Risk: high.
2. Three supplier contracts have change-of-control clauses triggered by the transaction. Risk: medium.
3. Deferred tax asset of EUR 0.4m has no supporting recoverability analysis. Risk: medium.
`;
// Stand-in for your back office. In production this starts a real job and returns its id.
async function startWorkflow( workflow, who, conversationId ) {
console.log( 'start', workflow, who, conversationId );
return 'wf-2026-09-08-0001';
}
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, conversationId } = JSON.parse( req.body );
const who = turn.content.find( part => part.type === 'delegation-context' )?.data ?? {};
// In production your agent reads the message and decides. Here it is the keyword map above.
const intent = classify( turn.prompt );
switch ( intent.kind ) {
// Path 1: a back-office job. Start it and answer the chat ourselves.
case 'start-workflow': {
const workflowId = await startWorkflow( intent.workflow, who, conversationId );
res.json( {
directive: 'halt',
reply: [ `Started the ${ intent.workflow }. I will post the result here when it is done.` ],
attributes: { workflowId }
} );
break;
}
// Path 2: an edit that needs your data. Fetch it and brief the CKEditor AI agent.
case 'enrich': {
res.json( {
directive: 'continue',
reply: [ 'Found the deal review findings. Drafting the changes now.' ],
forward: {
context: [ FINDINGS ],
instructions: [ 'Use only the findings provided. Do not invent figures.' ]
},
attributes: { engagementId: who.engagementId }
} );
break;
}
// Path 3: nothing to add. Hand the turn over as it is.
default: {
res.json( { directive: 'continue' } );
}
}
} );
app.listen( 3010 );Copy codeSave the code as server.js next to verify.js and start it with the same secret as in the hook configuration:
CKEDITOR_AI_HOOK_SECRET=whsec_<secret> node server.jsCopy codeThe 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()hands you a parsed object, and re-serializing it produces different bytes. Verify the text as it arrived. - The envelope can be large: the parser’s default limit of 100 kB is below what an envelope with attached files carries, and a
413fails the turn. Raise it to the 32 MB the service can send. - A
haltneeds a non-emptyreply: your endpoint is the assistant for that turn. forwardis not stored:forward.contextis background, andforward.instructionsare orders for this turn. Send them again on the next turn that needs them.attributesnever reach the prompt: they merge into the stored assistant message, on top of any attributes the client sent with the user message. That is how the workflow id reaches the front end.
Three requests run the enrich path. You create a conversation, upload a report, and send a message that edits the report. 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 codeCreate a conversation.
await post( '/v1/conversations', { id: 'engagement-4711-report' } );Copy codeUpload the report. The findings go into section 3. The response carries the document id, and the message below uses it.
const { id: documentId } = await post( '/v1/conversations/engagement-4711-report/documents', {
content: '<h1>Engagement 4711: financial due diligence</h1><h2>1. Scope</h2><p>The Company engaged us to review the financial position of the target ahead of the proposed acquisition.</p><h2>2. Approach</h2><p>We reviewed management accounts, the audited statements for FY2024 and FY2025, and the principal supplier contracts.</p><h2>3. Key findings</h2><p>To be completed.</p><h2>4. Next steps</h2><p>Findings will be discussed with management on the next call.</p>'
} );Copy code{ "id": "YzvocFJUh_krNBj59I2vN" }Copy codeSend the message. The delegation-context part tells your endpoint which engagement the user works on. The CKEditor AI agent never reads this part. The service stores it on the user message, and it comes back from GET /v1/conversations/{id}/messages. The script prints each event and its data line as they arrive.
await stream( '/v1/conversations/engagement-4711-report/messages', {
prompt: 'Add the key findings from the deal review to section 3.',
model: 'agent-1',
content: [
{ type: 'document', id: documentId },
{ type: 'delegation-context', id: 'who', data: { engagementId: '4711' } }
]
} );Copy codeThe reply from your endpoint arrives first, as the start of the assistant message, and the service inserts a newline after the reply. Then comes the edit to section 3 as one modification-delta, followed by the CKEditor AI agent’s short summary:
The stream: the hook's reply, one modification-delta, and the summary.
event: message-metadata
data: {"messageId":"VUk_7Facrcbw25_s5xTC1"}
event: text-delta
data: {"textDelta":"Found the deal review findings. Drafting the changes now."}
event: text-delta
data: {"textDelta":"\n"}
event: modification-delta
data: {"id":"ZbBR26KnAZwUtee3shw50","documentId":"YzvocFJUh_krNBj59I2vN","textDelta":"<h1>Engagement 4711: financial due diligence</h1><h2>1. Scope</h2><p>The Company engaged us to review the financial position of the target ahead of the proposed acquisition.</p><h2>2. Approach</h2><p>We reviewed management accounts, the audited statements for FY2024 and FY2025, and the principal supplier contracts.</p><h2>3. Key findings</h2><ol data-id=\"new-element\"><li data-id=\"new-element\">Revenue for FY2025 includes EUR 1.2m recognised before delivery. <strong>Risk rating: High.</strong></li><li data-id=\"new-element\">Three supplier contracts have change-of-control clauses triggered by the transaction. <strong>Risk rating: Medium.</strong></li><li data-id=\"new-element\">A deferred tax asset of EUR 0.4m has no supporting recoverability analysis. <strong>Risk rating: Medium.</strong></li></ol><h2>4. Next steps</h2><p>Findings will be discussed with management on the next call.</p>"}
event: text-delta
data: {"textDelta":"Section "}
event: text-delta
data: {"textDelta":"3 "}
event: text-delta
data: {"textDelta":"now "}
...
event: text-delta
data: {"textDelta":"rating."}
event: conversation-title
data: {"conversationTitle":"Adding Deal Review Findings to Section Three"}Copy codeThe findings came from the endpoint. The numbered list with a risk rating, the British spelling, and “the Company” came from the house-style context. Change the prompt to “Run the full independence check on this engagement.” for the workflow path, or to “Translate the summary into German.” for the pass-through path.
On the workflow path, the attributes field of the assistant message holds the workflow id. Your front end fetches the messages, reads the id, and polls your own API for that workflow. The stream does not include attributes, and the chat plugin in the editor does not expose them.
GET /v1/conversations/engagement-4711-report/messages
Authorization: Bearer <user-token>Copy code{
"items": [
{
"role": "user",
"id": "dAaimX75lbj88GTdY6bO-",
"prompt": "Run the full independence check on this engagement.",
"model": "agent-1",
"content": [
{ "type": "document", "id": "YzvocFJUh_krNBj59I2vN", "selection": [] },
{ "type": "delegation-context", "id": "who", "data": { "engagementId": "4711" } }
],
"createdAt": "2026-09-08T06:32:54.479Z"
},
{
"role": "assistant",
"id": "DWogvtAZXsy3z-M2zNR4D",
"status": "completed",
"content": [ { "type": "text", "content": "Started the independence check. I will post the result here when it is done." } ],
"attributes": { "workflowId": "wf-2026-09-08-0001" },
"createdAt": "2026-09-08T06:32:59.981Z"
}
]
}Copy code