# Hook endpoint for your own agent in Node.js

This example is the code behind [Extend your agent’s capabilities](../../guides/ckeditor-ai/extend-your-agent.md): a [hook](../../guides/ckeditor-ai/extensions/hooks.md) endpoint that runs your agent on every chat message, a [context](../../guides/ckeditor-ai/extensions/context-library.md) with your house style, and the requests that drive one message through the setup. Read that page first for what the pieces are for.

> **Warning**
>
> 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.

<a id="dependencies">

## Dependencies

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"`.

```bash
npm install express@5.1.0
```

The 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](../../onpremises/ckeditor-ai-onpremises/hooks.md#example-endpoint).

```js
// 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 ) );
}
```

<a id="example">

## Example

<a id="the-house-style-context">

### The house-style context

Create 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.

```http
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."
}
```

```http
POST /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."
}
```

```http
PATCH /v1/admin/contexts/firm-house-style
Content-Type: application/json
Authorization: Bearer <admin-token>

{
  "autoApply": {
	"features": [ "conversations" ]
  }
}
```

<a id="the-hook-configuration">

### The hook configuration

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](../../onpremises/ckeditor-ai-onpremises/hooks.md) for all options.

```json
{
	"hooks": {
		"endpoint": "https://agent.firm.example/ckeditor-ai/turn-start",
		"enabled": ["turn.start"],
		"timeoutMs": 60000,
		"auth": {
			"type": "shared-secret",
			"secret": "whsec_<secret>"
		}
	}
}
```

<a id="test-it-against-a-local-deployment">

### Test it against a local deployment

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`.

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

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`.

<a id="the-endpoint">

### The endpoint

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 in `attributes` for the front end.
* **Enrich:** the message needs your data. The endpoint fetches the data and answers `continue`, with the data in `forward.context` and a rule in `forward.instructions`.
* **Pass through:** the message needs nothing from you. The endpoint answers `continue` with no `forward`.

```js
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 );
```

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

```bash
CKEDITOR_AI_HOOK_SECRET=whsec_<secret> node server.js
```

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()` 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 `413` fails the turn. Raise it to the 32 MB the service can send.
* **A `halt` needs a non-empty `reply`:** your endpoint is the assistant for that turn.
* **`forward` is not stored:** `forward.context` is background, and `forward.instructions` are orders for this turn. Send them again on the next turn that needs them.
* **`attributes` never 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.

<a id="usage">

## Usage

<a id="run-one-message-through-it">

### Run one message through it

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.

```js
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` );
		}
	}
}
```

**Create a conversation.**

```js
await post( '/v1/conversations', { id: 'engagement-4711-report' } );
```

**Upload the report.** The findings go into section 3. The response carries the document id, and the message below uses it.

```js
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>'
} );
```

```json
{ "id": "YzvocFJUh_krNBj59I2vN" }
```

**Send 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.

```js
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' } }
	]
} );
```

The `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.

```html
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"}
```

The 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.

<a id="read-the-workflow-id-back">

### Read the workflow id back

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.

```http
GET /v1/conversations/engagement-4711-report/messages
Authorization: Bearer <user-token>
```

```json
{
  "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"
	}
  ]
}
```

---

Full index of the Cloud Services documentation: [llms.txt](../../../llms.txt)
