Sign up (with export icon)

Hooks

Show the table of contents

A chat turn is one user message and its reply. Hooks call an endpoint you host at fixed points of a turn. Your endpoint reads the turn, then answers the user itself or hands the turn to our agent with context and instructions of your own.

To build that endpoint, you set the hooks option, verify the signature on each request, and return a halt or a continue decision. One event exists today, turn.start. It fires after the user sends a chat message and before our agent runs.

For what a hook is for and when to use one, see Hooks in the CKEditor AI guides.

Note

CKEditor AI On-Premises ships no endpoint for hooks to call. Hooks connect a service you build and operate.

Hooks run only for the events listed in hooks.enabled. Without the hooks option, none run. To turn hooks off without losing the configuration, set enabled to an empty array.

Prerequisites

Copy link
  • An HTTPS endpoint you host: it accepts a POST with a JSON body and answers with a decision.
  • An answer within the timeout: every chat turn waits for your endpoint, so measure your service’s response time first.
  • A shared secret: we sign every request with it, and your endpoint uses it to reject calls that did not come from us.
Note

One endpoint serves the whole deployment. Every environment calls the same URL with the same secret.

Configuration

Copy link

All hook settings live under the hooks option. This example sends every chat turn to your endpoint and waits up to 30 seconds:

{
	"hooks": {
		"endpoint": "https://agent.example.com/ckeditor-ai",
		"enabled": ["turn.start"],
		"auth": {
			"type": "shared-secret",
			"secret": "whsec_c2VjcmV0LWJ5dGVzLWdvLWhlcmUtcGFkZGVk"
		}
	}
}
Copy code

Or pass it as the HOOKS environment variable, containing a stringified JSON:

docker run --init -p 8000:8000 \
	# ... other environment variables ...
	-e HOOKS='{"endpoint":"https://agent.example.com/ckeditor-ai","enabled":["turn.start"],"auth":{"type":"shared-secret","secret":"whsec_c2VjcmV0LWJ5dGVzLWdvLWhlcmUtcGFkZGVk"}}' \
	docker.cke-cs.com/ai-service:[version]
Copy code

Configuration options

Copy link
Option Required Default Description
endpoint yes – The URL we call. Use https. See Endpoint requirements.
enabled yes – Which hook events call your endpoint, as an array. An empty array configures the endpoint but calls nothing.
auth yes – The shared secret we sign requests with. See Authentication.
responseMode no json One JSON object or an NDJSON stream. See Response modes.
timeoutMs no 30000 How long the whole exchange may take. Minimum 1000, maximum 600000. Applies in both response modes.
streamIdleTimeoutMs no 30000 The longest silence we tolerate in stream mode: the wait for the response headers, and each gap between lines. Minimum 1000, maximum 60000. Ignored in json mode.
outcomeMaxEntries no 50 How many entries forward.instructions and forward.context may each carry. Minimum 1, maximum 500.
outcomeMaxCharacters no 20000 How many characters reply, forward.instructions, and forward.context may each carry in total, and each streamed channel in stream mode. Minimum 1000, maximum 100000.

The table lists the supported values. The service does not check them at start. It starts with any HOOKS value that is a JSON object, and a mistake shows only when a chat turn runs. A HOOKS value that is not a JSON object stops the start with Incorrect "hooks" configuration. Please provide a valid object. See Configuration mistakes for what each mistake does.

Configuration mistakes

Copy link

After each change to HOOKS, send one chat message. If it fails, find the reason in the service log.

Mistake What happens
enabled is missing, null, a number, or an object Every chat turn fails with a 500 error. Chat is down for all users.
enabled is an empty array or lists no known event No hook runs. Chat works without your endpoint.
endpoint is missing, is not a URL, or has its scheme in upper case, such as HTTPS:// Every chat turn fails with 502 hook-failed.
endpoint uses http:// Chat turns work. The request travels unencrypted. See Endpoint requirements.
auth or auth.secret is missing or malformed Every chat turn fails. See The secret.
auth.type is not shared-secret We ignore the value and sign with auth.secret.
timeoutMs is 0, negative, or not a number, such as "30s" Every chat turn fails with 504 hook-timeout.
streamIdleTimeoutMs is 0, negative, or not a number, in stream mode Every chat turn fails with 504 hook-timeout.
timeoutMs or streamIdleTimeoutMs is positive but outside its range We use the value as given.
outcomeMaxEntries is 0 Every decision with an entry in forward.instructions or forward.context fails with 502 hook-failed.
outcomeMaxCharacters is 0 Every decision with text in reply, forward.instructions, or forward.context fails with 502 hook-failed. Every halt fails. In stream mode, every line before the decision fails the turn.
outcomeMaxEntries or outcomeMaxCharacters is not a number The cap is off. Any number of entries and characters reaches our agent’s prompt.
responseMode is not json or stream Not supported. We call your endpoint as in json mode, and a streamed answer fails with 502 hook-failed.

Endpoint requirements

Copy link

Redirects are not followed. A 3xx answer fails the turn. Configure the final URL.

A private address is allowed. The endpoint can be a host that the internet cannot reach. The service does not filter endpoint addresses.

Authentication

Copy link

Your endpoint must reject any request that did not come from CKEditor AI. The required auth option is how we prove a call is ours.

The signature is the only credential in the request. If your endpoint is behind a gateway that requires its own API key, put the hook route on a path that does not require that key.

How we sign

Copy link

We sign every request with an HMAC keyed by your shared secret. The example endpoint includes a complete verification you can copy.

Every request carries these headers:

Header Value
webhook-id The request’s requestId, which is also in the body.
webhook-timestamp Unix time in seconds, as a decimal string.
webhook-signature v1, followed by the base64 signature.

The signature is HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}. The body is the raw request body exactly as sent. Nothing from the URL or the method is signed, so a proxy that rewrites the path does not break verification.

Reject a request if its webhook-timestamp is more than five minutes from your own clock. Reject a webhook-id that you have already handled. These two checks stop replay attacks.

The secret

Copy link

auth.secret is the shared secret. We sign with the exact value you write here, and there is nothing to register anywhere else.

The secret is standard, padded base64 behind a whsec_ prefix. It must decode to at least 24 bytes. Generate one with:

echo "whsec_$( openssl rand -base64 32 )"
Copy code

Give the same value to your endpoint. There is no key rotation, so change the secret in the configuration and in your endpoint at the same time.

If auth or the secret is missing or malformed, no request is sent, and every turn fails with 502 hook-failed. The log reason is signing-failed, so you can tell a configuration mistake from an outage.

Hook events

Copy link

Each event fires at a specific moment of a chat turn.

How a chat turn with a hook runs.

Event When it fires Decisions
turn.start After the user sends a message. Before our agent runs. halt, continue

turn.start

Copy link

Fires on every chat message. Your endpoint decides whether our agent runs at all.

turn.start input

Copy link

Your endpoint receives a POST with this JSON body. This example shows a turn with an open document, an attached file, and data from your front end:

{
  "contractVersion": "1",
  "event": "turn.start",
  "requestId": "3f2a9c1e-4b7a-4e2a-9c31-7a2e6c1d9f00",
  "environmentId": "env-123",
  "conversationId": "conv-456",
  "userId": "user-789",
  "turn": {
	"prompt": "Rewrite the introduction to be more concise.",
	"content": [
	  { "type": "document", "id": "doc-1", "selection": [] },
	  { "type": "delegation-context", "id": "ctx-1", "data": { "engagementId": "4711" } },
	  { "type": "file", "id": "file-1", "name": "brief.pdf", "mediaType": "application/pdf", "size": 48213 }
	],
	"attributes": { "locale": "en-US" }
  }
}
Copy code

Where:

  • contractVersion (always) – the envelope version. Allowed values: "1". See Staying compatible.
  • event (always) – the event that fired.
  • requestId (always) – identifies this call. Also travels as the webhook-id header. Comes back in every error we report.
  • environmentId (always) – the environment the turn belongs to.
  • conversationId (always) – the conversation. Stable across all its turns.
  • userId (always) – the signed-in user, as your token endpoint identified them.
  • turn.prompt (always) – what the user typed.
  • turn.content (always) – everything the turn works with, as an array of tagged parts. See Content parts.
  • turn.attributes (optional) – attributes the client attached to the message. Absent when it attached none. They reach your endpoint and never the prompt.

Content parts

Copy link

turn.content is a list of tagged parts of these kinds:

type What it is Fields
document A document open in the editor id, selection (an empty array when nothing is selected), optional editorConfig. A reference: read the text through our API.
file A file in the conversation id, name, mediaType, size. A description: read the bytes through our API.
web-resource A page in the conversation id, url, mediaType, size. A description: read the bytes through our API.
delegation-context Data your own front end attached to the message id, data. The data object is yours. It reaches your endpoint untouched. Our agent never sees it.

A delegation-context part stops at your endpoint. To put your own data in front of our agent, send it as forward.context on a continue decision.

turn.content lists every document, file, and web resource the turn works on, each one once. That includes the ones the message did not attach, because an earlier message in the conversation already did. With a multi-root editor, the list can run to a hundred entries.

A request body above 32 MB is never sent, and the turn fails. That limit is not configurable.

Reading attachments and documents

Copy link

A file or web-resource part describes an attachment and carries no content.

The request carries no credential for our API. To read an attachment, a document, or the earlier messages, your service calls our REST API with a token of its own for the same user and environment. The token needs the ai:conversations:read permission. Key each call on the conversationId and on the part’s id:

  • GET /v1/conversations/{conversationId}/files/{fileId}/content – the bytes of a file.
  • GET /v1/conversations/{conversationId}/web-resources/{webResourceId}/content – the bytes of a web resource.
  • GET /v1/conversations/{conversationId}/documents/{documentId} – the text of a document.
  • GET /v1/conversations/{conversationId}/messages – the earlier messages.

Decisions

Copy link

Answer 200 with a decision in the body. Do not use the HTTP status to carry the outcome. A refusal is a 200 with a halt decision.

There are two decisions:

  • halt – your service answers the user. Our agent does not run.
  • continue – our agent answers. You can add forward.instructions and forward.context for this turn, state the outcome in forward.intent instead of leaving it to our classifier, and stream a reply to the user ahead of our agent’s answer.

What the user ends up with after each decision.

halt: your service answers

Copy link
{
  "directive": "halt",
  "reply": ["Engagement 4711 is a fixed-fee statutory audit. Tax advisory is out of scope."],
  "attributes": { "engagementId": "4711" }
}
Copy code

Your text becomes the assistant’s message. Our agent never runs. The reply is stored like any other message, and the next turn sees it in the history.

Use halt whenever the answer is yours to give, including refusals.

continue: your service steers our agent

Copy link
{
  "directive": "continue",
  "reply": ["Checked the engagement policies. Drafting now."],
  "forward": {
	"context": ["Engagement 4711 is a fixed-fee statutory audit; scope excludes tax advisory."],
	"instructions": ["Use British English.", "Never name the client directly."]
  },
  "attributes": { "engagementId": "4711" }
}
Copy code

Our agent runs the turn, shaped by what you sent:

  • forward.context is background. We frame it as information, not as user input or an instruction.
  • forward.instructions apply to this turn only. Where they conflict with the custom instructions in the system prompt, forward.instructions apply. They cannot override the scope boundaries and the safety rules of the system prompt.
  • reply is a preamble that streams to the user before our agent produces a word. It is stored as the opening of the assistant’s message.

We put forward.context and forward.instructions in the prompt before the user’s question.

Neither forward field is stored or carried to the next turn. A later turn that needs the same context has to send it again.

What decides the outcome

Copy link

continue hands the turn to our agent, and the turn ends in a chat answer, in document edits, or in both. A classifier decides which, from the user’s question, the documents the turn works on, the recent messages, and your forward:

  • We weigh forward.context as evidence about what the turn is for, never as a request to change the document.
  • forward.instructions bind our agent for this turn, so they outrank the user’s question. An instruction to rewrite a section settles the turn as an edit, and an instruction that leaves no room to change the document settles it as a chat answer.

The classifier runs on a small model with a two-second budget. A turn that works on no document produces a chat answer, and no model is called.

A classifier call that times out or fails leaves the turn unclassified: our agent keeps every document-editing tool and carries no language instruction. State forward.intent when the outcome matters to you.

State what the turn produces

Copy link

When your service already knows how the turn ends, state it in forward.intent and we skip the classifier:

{
  "directive": "continue",
  "forward": {
	"context": ["Engagement 4711 is a fixed-fee statutory audit."],
	"intent": { "expectedOutput": "chat", "responseLanguage": "en" }
  }
}
Copy code

expectedOutput names what the turn produces:

Value What we tell our agent
chat Answer in chat. The document-editing tools are withheld for the turn.
document-modification Edit the document, with brief explanations of the edits at most.
selection-modification Edit only the part of the document the user selected.
both Edit the document and answer in chat.

Only chat withholds tools. The other three values keep the editing tools available and do not force an edit. Our agent still reads the prompt, so it can answer a general question in chat only.

The other three fields shape how our agent produces that output:

  • responseLanguage – ISO 639-1 code for the chat answer.
  • documentModificationLanguage – ISO 639-1 code for document edits. Name a different language to translate.
  • requiresReasoning – false turns reasoning off for the turn, unless the user turned it on in the editor.

A stated intent replaces the whole classification rather than adjusting it, and nothing fills in the fields you leave out. An intent that names expectedOutput and omits both languages leaves the turn with no language instruction at all, so state the languages alongside the outcome.

We apply a stated intent as it arrives and validate it against nothing in the turn. selection-modification on a turn with no selection, document-modification on a turn with no document, and a language code no model knows all reach our agent as you sent them.

The expectedOutput field is required. An intent that omits it fails the turn.

Decision fields

Copy link

directive picks the decision. Any other value fails the turn.

A halt decision carries:

Field Required Type and bounds
directive yes "halt".
reply yes Array of strings, joined with newlines. outcomeMaxCharacters in total. In stream mode, reply-delta lines can carry the text instead.
attributes no Object with string keys. Not size-checked. Never reaches the prompt.

A continue decision carries:

Field Required Type and bounds
directive yes "continue".
reply no Array of strings, joined with newlines. outcomeMaxCharacters in total. Streamed as a preamble ahead of our agent’s output.
forward.instructions no Array of strings. outcomeMaxEntries entries, outcomeMaxCharacters in total.
forward.context no Array of strings. outcomeMaxEntries entries, outcomeMaxCharacters in total.
forward.intent no Object. Replaces our intent classification for this turn. Not size-checked.
attributes no Object with string keys. Not size-checked. Never reaches the prompt.

A forward.intent object carries:

Field Required Type and bounds
expectedOutput yes "chat", "document-modification", "selection-modification", or "both".
responseLanguage no ISO 639-1 code for the chat answer.
documentModificationLanguage no ISO 639-1 code for document edits.
requiresReasoning no Boolean. false turns reasoning off for the turn, unless the user turned it on in the editor.

A halt whose text is blank fails the turn.

We merge attributes into the metadata of the stored message. If a key is also in the attributes the client sent, the value from your decision replaces it. Your front end can read the metadata back.

Each cap counts per array, and a decision over one fails the turn. Raise them with outcomeMaxEntries and outcomeMaxCharacters, up to 500 entries and 100,000 characters. Keep instructions short whatever the cap: they enter the prompt on every turn.

Only your reply enters the conversation history. The request and decision envelopes are logged and traced but never stored as messages.

Response modes

Copy link

responseMode picks one of two response shapes. Both carry the same decision.

json, the default stream
What the user sees while your service works Nothing. The chat waits. Progress lines, then the reply appearing as you write it.
What you build A handler that returns one JSON body. A handler that writes NDJSON lines and holds the connection open until it decides.
What the timeouts bound timeoutMs: the whole answer. timeoutMs: the whole exchange. streamIdleTimeoutMs: the wait for the headers, and the silence between lines.
If the exchange fails partway The turn fails. The user sees only an error. The progress lines and the reply text already sent stay in the chat. The error message follows them.
A halt decision Must carry the reply. Can carry it, or stream the text as deltas and decide with no reply at all.

If your service answers in a second or two, answer once. Stream when it takes longer, or when you want the user to see progress.

json: answering once

Copy link

Reply 200 with the decision as the whole body and content-type: application/json. The examples above are complete answers.

stream: answering as you work

Copy link

Reply 200 with content-type: application/x-ndjson. Write NDJSON: one JSON object per line, flushed as you go. Any other content type fails the turn before we read a line. We send accept: application/x-ndjson so your handler can tell the modes apart.

{"type":"progress","text":"Checking 14 engagement policies…"}
{"type":"progress","text":"Consulting the governance agent…"}
{"type":"reply-delta","text":"The engagement is "}
{"type":"reply-delta","text":"a fixed-fee statutory audit."}
{"directive":"halt"}
Copy code
Line What the user sees
{"type":"progress","text":"…"} A status line in the chat, separate from the reply.
{"type":"reply-delta","text":"…"} The assistant’s reply, appearing as it is written.
A line with a directive The decision. It ends the stream.

The decision line is the same object you would send as a JSON body. A stream that ends without one fails the turn, even after a complete reply.

Each channel has its own cap of 20,000 characters across the whole stream: the progress lines share one, the reply-delta lines another, and the steering lines a third. outcomeMaxCharacters raises all three. A stream that goes over a cap fails the turn.

A line we do not recognize is ignored, so a handler written against a newer contract still works. Send only the line kinds above: an unrecognized line is charged to the steering channel at its full length, and enough of them fail the turn.

Steering: building the forward as you go

Copy link

A steering line carries forward and attributes, and it can arrive at any point in the stream. Any of these patterns works:

Send only user-facing lines, then decide. The whole forward goes on the decision line at the end. Use this when your service only knows what to forward once it has finished.

Build the forward in pieces. Send each piece as soon as you have it. Every steering line adds to the lines before it. Do not send the same entry twice.

Mix the two. steering and progress lines are independent. The same stream can tell the user “checking the engagement file” while it loads that file into forward.context. Steering never reaches the user.

A stream that uses all of it:

{"type":"progress","text":"Looking up engagement 4711…"}
{"type":"steering","forward":{"context":["Engagement 4711 is a fixed-fee statutory audit."]}}
{"type":"progress","text":"Checking the scope exclusions…"}
{"type":"steering","forward":{"context":["Scope excludes tax advisory."],"instructions":["Never name the client directly."]}}
{"type":"steering","forward":{"instructions":["Use British English."]},"attributes":{"styleGuide":"uk-2026"}}
{"directive":"continue","reply":["Checked the engagement policies. Drafting now."],"forward":{"instructions":["Keep the introduction under 120 words."],"intent":{"expectedOutput":"both","responseLanguage":"en","documentModificationLanguage":"en"}}}
Copy code

The merge rules:

  • context and instructions concatenate in arrival order, with the decision line last.
  • intent replaces rather than accumulates. The last one stated wins whole, the decision line included, and the fields an earlier intent set do not survive into it.
  • attributes merge key by key. A later line wins a shared key, the decision line included.
  • The caps apply to each line and again after the merge. If one line is over the cap, we drop that field from the line. If the merged forward is over the cap, the turn fails. intent is not size-checked, and it is dropped along with the rest of a forward that is.
  • A halt decision discards the forward from every steering line. The attributes from those lines still reach the stored message.

A malformed fragment is dropped, and the turn goes on. Each field is checked on its own.

Example endpoint

Copy link

A complete endpoint, in Node with Express:

import express from 'express';
import { Buffer } from 'node:buffer';
import { createHmac, timingSafeEqual } from 'node:crypto';

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

const app = express();

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

	const { turn } = JSON.parse( req.body );

	if ( isOutOfScope( turn.prompt ) ) {
		res.json( { directive: 'halt', reply: [ 'I cannot advise on that. Ask the compliance desk.' ] } );
		return;
	}

	res.json( {
		directive: 'continue',
		forward: { context: [ lookUpCaseFile( turn ) ] },
		attributes: { reviewedBy: 'policy-agent' },
	} );
} );

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

For production, also remember the webhook-id values you have accepted and refuse a repeat. Log requestId on every call: it ties your logs to ours for the same turn.

When a turn fails

Copy link

The user’s request ends with an error. No assistant message is stored.

What happened HTTP status Error code
Your endpoint did not answer within timeoutMs 504 hook-timeout
In stream mode, your endpoint sent no response headers within streamIdleTimeoutMs 504 hook-timeout
We could not reach your endpoint 502 hook-failed
Your endpoint answered with a redirect or a status outside 2xx 502 hook-failed
The response body is not valid JSON, or in stream mode the content type is not application/x-ndjson 502 hook-failed
The request envelope or the response body is over 32 MB 502 hook-failed
The decision is outside the contract: an unknown directive, a missing required field, or a value over a cap 502 hook-failed
The service cannot read the hook secret to sign the request 502 hook-failed

The response body carries the error code and a requestId. The requestId is the same value as in the request body and the webhook-id header, so you can match a failed turn to your own logs. The service log records the reason for the failure with the same requestId.

In stream mode, the table applies until your endpoint answers 200 with the NDJSON content type. After that, a failure, such as a timeout, a stall, or a decision outside the contract, reaches the user as an error event in the reply stream. The progress lines and the reply text already sent stay on screen.

When the user cancels

Copy link

If the user stops the request before your endpoint answers, we close the connection to your endpoint at once, in both response modes. Your endpoint sees the request abort. The service logs it as a cancellation, not as a failure. No message is stored for the turn.

Staying compatible

Copy link

contractVersion is "1". Within one version, the contract only gains fields: new envelope fields and new stream line kinds. A handler that you write today continues to work.

Two rules make that promise safe:

  • Ignore envelope fields you do not recognize. More will arrive.
  • We ignore decision fields we do not recognize. Extra keys in your response are dropped without failing the turn. Put your own bookkeeping in attributes.

A change that would break a version 1 handler comes with a new contractVersion.

Limits

Copy link
Limit Value Configurable
Whole exchange, both modes 30 s, up to 600 s timeoutMs
Wait for headers, and silence between streamed lines 30 s, up to 60 s streamIdleTimeoutMs
Request body 32 MB no
Response body in json mode 32 MB no
Entries in instructions and context 50 per array, up to 500 outcomeMaxEntries
Characters in reply, instructions, context 20,000 per array, up to 100,000 outcomeMaxCharacters
Streamed characters, per channel 20,000, up to 100,000 outcomeMaxCharacters