Hooks
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.
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.
Hooks fail closed. If your endpoint errors, redirects, times out, or answers outside the contract, the chat turn fails. We do not fall back to our own agent. While your endpoint is down, no chat turn completes.
- An HTTPS endpoint you host: it accepts a
POSTwith 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.
One endpoint serves the whole deployment. Every environment calls the same URL with the same secret.
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 codeOr 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| 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.
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. |
Use an https:// endpoint. The service does not check the scheme, so an http:// endpoint receives the whole request unencrypted. The request body carries the user’s prompt, the names and URLs of their attachments, your delegation-context data and attributes, and the user and document IDs. None of it is redacted.
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.
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.
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.
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 codeGive 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.
The secret sits in your configuration as plain text, like your LLM provider API keys. Protect the configuration the same way.
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.
Each event fires at a specific moment of a chat turn.

| Event | When it fires | Decisions |
|---|---|---|
turn.start |
After the user sends a message. Before our agent runs. | halt, continue |
Fires on every chat message. Your endpoint decides whether our agent runs at all.
We call you before our own checks run. Content moderation and prompt-injection detection guard our agent, not yours. The prompt in this request, and the attachments your service reads through our API, are whatever the user sent. Screen them yourself.
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 codeWhere:
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 thewebhook-idheader. 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.
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.
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.
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 addforward.instructionsandforward.contextfor this turn, state the outcome inforward.intentinstead of leaving it to our classifier, and stream areplyto the user ahead of our agent’s answer.

{
"directive": "halt",
"reply": ["Engagement 4711 is a fixed-fee statutory audit. Tax advisory is out of scope."],
"attributes": { "engagementId": "4711" }
}Copy codeYour 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.
{
"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 codeOur agent runs the turn, shaped by what you sent:
forward.contextis background. We frame it as information, not as user input or an instruction.forward.instructionsapply to this turn only. Where they conflict with the custom instructions in the system prompt,forward.instructionsapply. They cannot override the scope boundaries and the safety rules of the system prompt.replyis 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.
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.contextas evidence about what the turn is for, never as a request to change the document. forward.instructionsbind 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.
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 codeexpectedOutput 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–falseturns 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.
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.
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.
Reply 200 with the decision as the whole body and content-type: application/json. The examples above are complete answers.
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.
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 codeThe merge rules:
contextandinstructionsconcatenate in arrival order, with the decision line last.intentreplaces rather than accumulates. The last one stated wins whole, the decision line included, and the fields an earlierintentset do not survive into it.attributesmerge 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
forwardis over the cap, the turn fails.intentis not size-checked, and it is dropped along with the rest of aforwardthat is. - A
haltdecision discards theforwardfrom everysteeringline. Theattributesfrom those lines still reach the stored message.
A malformed fragment is dropped, and the turn goes on. Each field is checked on its own.
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 codeVerify against the raw request body. express.json() hands you a parsed object, and JSON.stringify on it produces different bytes, so the signature will never match. Use your framework’s raw-body option.
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.
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.
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.
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.
| 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 |