TinyMCE AI on-premises hooks
A hook calls an HTTPS endpoint hosted by the integrator at a fixed point in a chat turn. A chat turn is one user message and the reply to it. The hook endpoint reads the turn and returns a decision: answer the user itself, or let the AI service answer, optionally with added context and instructions.
The HOOKS environment variable configures hooks on the AI service container.
Requirements
-
An HTTPS endpoint hosted by the integrator. The AI service does not include a hook endpoint. The endpoint accepts a
POSTrequest with a JSON body and responds with a decision. -
A response within the timeout. Every chat turn waits for the endpoint. Measure the response time of the hook service before enabling hooks.
-
A shared secret. The AI service signs every request with it, and the endpoint uses it to reject requests that did not come from the AI service. See Request signing.
Scope
-
Hooks run for chat messages only. Quick actions, reviews, and Document Processing do not call hooks.
-
One event exists:
turn.start. It fires after the user sends a chat message and before the AI service calls a model. -
One endpoint and one shared secret serve the whole deployment. Every environment calls the same URL, and each request carries the
environmentIdof the turn. -
Hooks run only for the events listed in
enabled. WhenHOOKSis not set, no hooks run.
|
Hooks fail closed. When the hook endpoint returns an error, redirects, times out, or responds outside the contract on this page, the chat turn fails. The AI service does not fall back to answering the turn itself. While the endpoint is unavailable, no chat turn completes. |
Configuration
HOOKS holds a JSON object as a string. The following example sends every chat turn to the endpoint and waits up to 30 seconds for a decision:
docker run --init -d -p 8000:8000 \
--name ai-service \
--env-file ai-service.env \
-e HOOKS='{
"endpoint": "https://ai-hooks.example.com/turn",
"enabled": ["turn.start"],
"timeoutMs": 30000,
"auth": {
"type": "shared-secret",
"secret": "whsec_<base64-encoded-secret>"
}
}' \
registry.containers.tiny.cloud/ai-service-tiny:<version>
The ai-service.env file holds the rest of the service configuration, such as the license key, database, Redis, and provider settings. See Environment variable reference.
To turn hooks off without removing the configuration, set enabled to an empty array.
In a configuration file, set the same object as the value of the hooks key.
Options
| Option | Required | Default | Description |
|---|---|---|---|
|
Yes |
- |
The URL that the AI service calls. Must use |
|
Yes |
- |
The events that call the endpoint, as an array. Currently only |
|
Yes |
- |
The signing configuration: |
|
No |
|
|
|
No |
|
The time allowed for the whole exchange, in milliseconds. Minimum |
|
No |
|
The longest silence allowed in |
|
No |
|
The maximum number of entries in each of |
|
No |
|
The maximum total characters in each of |
|
No |
|
Allows an |
The AI service does not start when the HOOKS configuration breaks any of these rules.
Endpoint requirements
-
HTTPS. The AI service does not start with an
http://endpoint unlessallowPlaintextHttpistrue. -
No redirects. A
3xxresponse fails the turn. Configure the final URL. -
Private addresses are allowed. The endpoint can be a host that is not reachable from the Internet. The AI service does not filter endpoint addresses.
-
No gateway credential. The request signature is the only credential that the AI service sends. When the endpoint sits behind a gateway that requires an API key, expose the hook route on a path that does not require that key.
-
Response time. Set
timeoutMsabove the response time of the hook service under load. The hook call adds to the duration of the chat response, so the reverse proxy in front of the AI service must keep the response open for both. See Proxy requirements.
Set allowPlaintextHttp only when hook traffic never leaves a network under the operator’s control. The request body carries the user’s prompt, the names and URLs of attachments, delegation-context data and attributes, and the user and document IDs. None of it is redacted, and plain HTTP sends it unencrypted.
|
Request signing
The AI service signs every hook request with HMAC-SHA256. The key is the shared secret without its whsec_ prefix, decoded from base64 to bytes. The endpoint must verify the signature and reject any request that fails verification.
Each request carries these headers:
| Header | Value |
|---|---|
|
The |
|
The Unix time in seconds, as a decimal string. |
|
|
The AI service computes the signature over {webhook-id}.{webhook-timestamp}.{body}, where body is the raw request body exactly as sent. The signature does not cover the URL or the HTTP method, so a proxy that rewrites the path does not break verification.
To prevent replay attacks, the endpoint must also:
-
Reject a request whose
webhook-timestampdiffers from the endpoint’s clock by more than five minutes. -
Reject a
webhook-idthat it has already processed.
The shared secret
The secret is whsec_ followed by standard, padded base64 that decodes to at least 24 bytes. Generate one with OpenSSL:
echo "whsec_$(openssl rand -base64 32)"
Give the same value to the hook endpoint. Nothing needs to be registered elsewhere. There is no key rotation, so change the secret in HOOKS and in the endpoint at the same time.
When the secret is missing or malformed, the AI service sends no request and the turn fails. The log reason is signing-failed, which distinguishes a configuration mistake from an endpoint outage.
| The service configuration holds the secret as plain text, in the same way as LLM provider API keys. Store it in a secrets manager and protect it accordingly. |
Example endpoint in Node.js
The following Express endpoint verifies each request, rejects replays, and returns a decision. It refuses chat messages that mention payroll, and lets the AI service answer every other message with an added instruction. It requires Node.js 18 or later and the express package.
Create package.json:
{
"name": "tinymceai-hook-endpoint",
"type": "module",
"private": true,
"dependencies": {
"express": "^4.18.0"
}
}
Create hook-server.js:
// hook-server.js
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const HOOK_SECRET = process.env.AI_HOOK_SECRET; // whsec_<base64-encoded-secret>
const PORT = Number(process.env.PORT ?? 3001);
const MAX_CLOCK_SKEW_SECONDS = 300;
const REPLAY_WINDOW_MS = 10 * 60 * 1000;
if (!HOOK_SECRET || !HOOK_SECRET.startsWith('whsec_')) {
throw new Error('Set AI_HOOK_SECRET to the whsec_ value from the HOOKS configuration.');
}
const signingKey = Buffer.from(HOOK_SECRET.slice('whsec_'.length), 'base64');
// Request IDs seen recently, with the time each was seen.
// In production, keep these in a shared store with a time-to-live, such as Redis.
const seenRequestIds = new Map();
const isFreshRequest = (requestId) => {
const now = Date.now();
for (const [ id, seenAt ] of seenRequestIds) {
if (now - seenAt > REPLAY_WINDOW_MS) {
seenRequestIds.delete(id);
}
}
if (seenRequestIds.has(requestId)) {
return false;
}
seenRequestIds.set(requestId, now);
return true;
};
const hasValidSignature = (headers, rawBody) => {
if (!Buffer.isBuffer(rawBody)) {
return false;
}
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (typeof id !== 'string' || typeof timestamp !== 'string' || typeof signatures !== 'string') {
return false;
}
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(skew) || skew > MAX_CLOCK_SKEW_SECONDS) {
return false;
}
const expected = createHmac('sha256', signingKey)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
// The header holds one or more space-separated "v1,<base64>" entries.
return signatures.split(' ').some((entry) => {
const [ version, value ] = entry.split(',');
if (version !== 'v1' || !value) {
return false;
}
const received = Buffer.from(value, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
};
const app = express();
// Read the body as raw bytes. Re-serializing parsed JSON changes the bytes and breaks verification.
app.post('/turn', express.raw({ type: 'application/json', limit: '32mb' }), (req, res) => {
if (!hasValidSignature(req.headers, req.body)) {
res.sendStatus(401);
return;
}
if (!isFreshRequest(req.headers['webhook-id'])) {
res.sendStatus(409);
return;
}
const envelope = JSON.parse(req.body.toString('utf8'));
console.log(`requestId=${envelope.requestId} environment=${envelope.environmentId}`);
if (/\bpayroll\b/i.test(envelope.turn.prompt)) {
res.json({
directive: 'halt',
reply: [ 'Payroll questions are handled by the HR service desk.' ]
});
return;
}
res.json({
directive: 'continue',
forward: {
instructions: [ 'Use US English spelling.' ]
}
});
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Hook endpoint listening on port ${PORT}`);
});
Install the dependencies and start the endpoint with the shared secret:
npm install
AI_HOOK_SECRET='whsec_<base64-encoded-secret>' node hook-server.js
Publish the endpoint behind HTTPS, for example at https://ai-hooks.example.com/turn, and set that URL as endpoint in HOOKS.
Log the requestId of every request. The AI service logs the same value, so it links the endpoint’s logs to the service logs for the same turn.
The turn.start request
The AI service sends a POST request with a JSON body. The following example shows a turn with an open document, an attached file, and custom front-end data:
{
"contractVersion": "1",
"event": "turn.start",
"requestId": "6b1f0c2e-8d4a-4f61-9a7e-3c5d2b1a0f98",
"environmentId": "<environment-id>",
"conversationId": "tiny-2f6c1d0e-5b7a-4c3d-8e9f-1a2b3c4d5e6f",
"userId": "user-42",
"turn": {
"prompt": "Summarize the attached brief in three bullet points.",
"content": [
{ "type": "document", "id": "doc-1", "selection": [] },
{ "type": "file", "id": "file-1", "name": "brief.pdf", "mediaType": "application/pdf", "size": 48213 },
{ "type": "delegation-context", "id": "ctx-1", "data": { "ticketId": "SUP-1024" } }
],
"attributes": { "locale": "en-US" }
}
}
| Field | Description |
|---|---|
|
The envelope version. Currently |
|
The event that fired: |
|
The ID of this call. Also sent as the |
|
The environment of the turn. |
|
The conversation. Stable across all turns of the conversation. |
|
The user, as identified by the JSON Web Token (JWT) from the token endpoint. |
|
The text that the user sent. |
|
Everything the turn works with, as an array of typed parts. See Content parts. |
|
Optional. Attributes that the client attached to the message. Absent when the client attached none. They reach the hook endpoint and never reach the prompt. |
Content parts
type |
Meaning | Fields |
|---|---|---|
|
A document open in the editor |
|
|
A file in the conversation |
|
|
A web page in the conversation |
|
|
Data that the client attached to the message for the hook endpoint |
|
turn.content lists every document, file, and web resource that the turn works on, each one once. The list includes the ones that an earlier message in the conversation attached.
A delegation-context part stops at the hook endpoint. To pass that data to the model, return it in forward.context on a continue decision.
A request body larger than 32 MB is never sent, and the turn fails. This limit cannot be changed.
Reading attachments and documents
The request carries no credential for the TinyMCE AI API, and no attachment content, document text, or earlier messages. To read them, the hook service calls the REST API with its own token for the same user and environment. The token needs the ai:conversations:read permission. See Permissions reference.
Key each call on the conversationId from the request and the id of the part:
-
GET /v1/conversations/{conversationId}/files/{fileId}/contentreturns the bytes of a file. See Download file content. -
GET /v1/conversations/{conversationId}/web-resources/{webResourceId}/contentreturns the bytes of a web resource. See Download web resource content. -
GET /v1/conversations/{conversationId}/documents/{documentId}returns the text of a document. See Fetch a document. -
GET /v1/conversations/{conversationId}/messagesreturns the earlier messages. See Fetch conversation messages.
Decisions
The endpoint responds with HTTP status 200 and a decision in the body. Do not use the HTTP status to signal the outcome: a refusal is a 200 response with a halt decision.
halt: the hook endpoint answers
{
"directive": "halt",
"reply": ["Payroll questions are handled by the HR service desk."],
"attributes": { "handledBy": "policy-service" }
}
The reply becomes the assistant message, and the AI service does not call a model. The AI service stores the reply like any other message, and later turns see it in the conversation history. Use halt for every answer that the hook service gives itself, including refusals.
continue: the AI service answers
{
"directive": "continue",
"reply": ["Checked the ticket history. Drafting a summary now."],
"forward": {
"context": ["Ticket SUP-1024 is a migration request with a deadline of 30 November."],
"instructions": ["Use US English spelling.", "Do not name individual customers."]
},
"attributes": { "ticketId": "SUP-1024" }
}
The AI service answers the turn, shaped by the decision:
-
forward.contextis background information. The AI service presents it to the model 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.instructionstake precedence. They cannot override the scope and safety rules of the system prompt. -
replyis a preamble. It streams to the user before the model’s response, and the AI service stores it as the start of the assistant message.
The AI service places forward.context and forward.instructions in the prompt before the user’s question. The AI service does not store either field or carry it to the next turn. A later turn that needs the same context must send it again.
Stating the turn outcome
A continue turn ends in a chat answer, document edits, or both. By default, a classifier decides from the user’s question, the documents of the turn, recent messages, and the forward fields:
-
The classifier treats
forward.contextas evidence of what the turn is for, never as a request to change the document. -
forward.instructionstake precedence over the user’s question. An instruction to rewrite part of the document makes the turn an edit. An instruction that rules out document changes makes it a chat answer.
The classifier runs on a small model with a two-second time limit. A turn with no document ends in a chat answer without a classifier call. When the classifier times out or fails, the turn is unclassified: the model keeps every document-editing tool and receives no language instruction.
To skip the classifier, state the outcome in forward.intent:
{
"directive": "continue",
"forward": {
"context": ["Ticket SUP-1024 is a migration request."],
"intent": {
"expectedOutput": "chat",
"responseLanguage": "en"
}
}
}
| Field | Required | Description |
|---|---|---|
|
Yes |
|
|
No |
ISO 639-1 code for the chat answer. |
|
No |
ISO 639-1 code for document edits. A language different from the document language translates the document. |
|
No |
Boolean. |
Only chat withholds tools. The other three values keep the editing tools available and do not force an edit. The model still reads the prompt, so it can answer a general question in chat only.
A stated intent replaces the classification entirely. The AI service does not fill in omitted fields, so state the languages together with expectedOutput. The AI service does not validate the intent against the turn: for example, selection-modification on a turn with no selection reaches the model as sent. An intent without expectedOutput fails the turn.
Decision fields
| Field | halt |
continue |
Type and limits |
|---|---|---|---|
|
Required |
Required |
|
|
Required in |
Optional |
Array of strings, joined with newlines. Up to |
|
- |
Optional |
Array of strings. Up to |
|
- |
Optional |
Array of strings. Up to |
|
- |
Optional |
Object. Replaces the classification for this turn. Not size-checked. |
|
Optional |
Optional |
Object with string keys. Merged into the metadata of the stored message, replacing client attributes with the same key. Not size-checked, and never sent to the model. |
A decision that exceeds a limit fails the turn. Keep instructions short regardless of the limit, because they enter the prompt on every turn that the hook sends them on.
Only reply enters the conversation history. The AI service logs and traces the request and decision bodies, but does not store them as messages. See OpenTelemetry.
Response modes
responseMode selects how the endpoint returns its decision. Both modes carry the same decision.
json(default)-
Respond with
content-type: application/jsonand the decision as the whole body. The chat waits with no progress shown.timeoutMslimits the whole response. When the exchange fails, the user sees only an error. stream-
Respond with
content-type: application/x-ndjsonand write one JSON object per line, flushing each line. The AI service sendsaccept: application/x-ndjsonin this mode. Any other response content type fails the turn.timeoutMslimits the whole exchange, andstreamIdleTimeoutMslimits the wait for the response headers and each gap between lines. When the exchange fails partway, the progress lines and reply text already sent stay in the reply stream, followed by anerrorevent. See Troubleshooting.
Use json when the endpoint answers in a second or two. Use stream when it takes longer, or to stream progress lines to the client.
Stream lines
{"type":"progress","text":"Looking up ticket SUP-1024"}
{"type":"steering","forward":{"context":["Ticket SUP-1024 is a migration request."]}}
{"type":"reply-delta","text":"Here is a summary "}
{"type":"reply-delta","text":"of the ticket."}
{"directive":"halt"}
| Line | Effect |
|---|---|
|
A status line, separate from the reply. The AI service sends it to the client as a |
|
Part of the reply, shown as it arrives. A streamed |
|
Adds to |
A line with |
The decision, in the same shape as a |
A steering line can arrive at any point in the stream. The endpoint can send the whole forward on the decision line, send it in pieces on steering lines as the data becomes available, or combine both. Each steering line adds to the earlier ones, so do not repeat an entry.
Merge rules for steering lines:
-
contextandinstructionsare concatenated in arrival order, with the decision line last. -
intentis replaced, not merged. The last statedintent, including one on the decision line, applies in full. Fields from an earlierintentdo not carry over. -
attributesare merged key by key. A later line takes precedence, including the decision line. -
Limits apply to each line and again after merging. An oversized field in one line is dropped. An oversized merged
forwardfails the turn.intentis not size-checked, but when aforwardis dropped, itsintentis dropped with it. -
A
haltdecision discards every streamedforward. Streamedattributesare still stored. -
A malformed
steeringfragment is dropped, and the stream continues. Each field is checked separately.
The progress, reply-delta, and steering lines each have a limit of outcomeMaxCharacters across the whole stream. A stream that exceeds a limit fails the turn. The AI service ignores unrecognized lines, but counts them toward the steering limit at their full length.
Limits
| Limit | Default and maximum | Option |
|---|---|---|
Whole exchange, both modes |
30 s, up to 600 s |
|
Wait for the response headers, and each gap between streamed lines |
30 s, up to 60 s |
|
Request body |
32 MB |
Not configurable |
Response body in |
32 MB |
Not configurable |
Entries in |
50 for each array, up to 500 |
|
Characters in |
20,000 for each array, up to 100,000 |
|
Streamed characters for each channel |
20,000, up to 100,000 |
|
Contract versioning
contractVersion is "1". Within a version, the contract only gains fields, such as new envelope fields and new stream line types. To stay compatible:
-
Ignore unrecognized fields in the request.
-
Put custom bookkeeping in
attributes. The AI service ignores unrecognized decision fields without failing the turn.
A change that breaks version 1 handlers comes with a new contractVersion.
Integration patterns
Screening chat messages
A hook can check every chat message against organization rules and refuse the messages that break them. The endpoint returns halt with the refusal text for a message that breaks a rule, and continue for every other message. The example endpoint in Example endpoint in Node.js follows this pattern.
-
Screen
turn.prompt. Afileorweb-resourcepart carries no content, so to screen an attachment, read it through the API as described in Reading attachments and documents. -
When the rules call another service that is unavailable, do not respond with
continue. Respond withhaltand a refusal that the user can read, or with an error status, which fails the turn with the general chat error message. Both keep the check fail-closed. -
Hooks do not screen quick actions, reviews, or Document Processing. To apply the policy to every request from a group of users, grant only chat permissions in their tokens:
{
"auth": {
"ai": {
"permissions": [
"ai:conversations:read",
"ai:conversations:write",
"ai:models:openai:gpt-4.1"
]
}
}
}
Replace ai:models:openai:gpt-4.1 with the model permissions for the deployment. See Permissions reference. Token permissions are the control; hiding a toolbar button in the editor does not enforce anything.
A hook does not replace content moderation. A hook applies the integrator’s own rules to chat messages only, and fails closed. Moderation checks requests against a fixed set of harm categories, and fails open. See What moderation checks and Failure behavior.
Routing chat messages to an existing agent
A hook can pass each chat message to an existing in-house agent. The agent answers some messages itself with halt, for example a request to open a change request. For other messages, it returns continue with the data it looked up in forward.context, house style rules in forward.instructions, and, when the agent already knows the user wants a document edit, a forward.intent:
{
"directive": "continue",
"reply": ["Found the timeline for INC-2291."],
"forward": {
"context": [
"Timeline of INC-2291: 09:12 UTC error rate on the checkout service rose above 5%. 09:41 UTC configuration change rolled back. 09:55 UTC incident resolved."
],
"instructions": ["Write times in UTC with the 24-hour clock."],
"intent": {
"expectedOutput": "document-modification",
"responseLanguage": "en",
"documentModificationLanguage": "en"
}
},
"attributes": { "incidentId": "INC-2291" }
}
When the agent takes more than a second or two, set "responseMode": "stream" and send progress lines while the agent works. When the agent fails, respond with an error status. The turn fails with 502 hook-failed, and the AI service does not answer the message itself.
Security considerations
|
The AI service calls the hook endpoint before content moderation checks the prompt and before the |
-
Verify the signature on every request, and reject stale timestamps and repeated
webhook-idvalues. -
Use HTTPS for the endpoint.
-
Treat the hook endpoint as a dependency of every chat turn. Monitor its availability and response time, because an outage stops all chat turns. Run more than one instance, and keep request IDs for replay protection in a shared store.
Hooks and the TinyMCE AI plugin
The TinyMCE AI plugin needs no configuration for hooks. A halt reply and a continue reply both appear in the chat as assistant messages.
-
A custom chat interface built on the REST API can read
hook-progressevents and show each line. See Streaming. -
To send custom front-end data in the hook request, attach
delegation-contextparts to messages through the conversation API.
Troubleshooting
When a hook call fails, the user’s request ends with an error, and the AI service stores no assistant message.
| Cause | HTTP status | Error code |
|---|---|---|
The endpoint did not respond within |
|
|
In |
|
|
The AI service could not reach the endpoint |
|
|
The endpoint responded with a redirect or a status outside |
|
|
The response body is not valid JSON, or in |
|
|
The request body or the response body is larger than 32 MB |
|
|
The decision is outside the contract: an unknown |
|
|
The AI service cannot read the shared secret to sign the request |
|
|
The error response body carries the error code and the requestId, the same value as the webhook-id header and the requestId in the request body, so a failed turn can be matched to the logs of the hook endpoint. The service log records the reason for the failure with the same requestId.
In stream mode, the table applies until the endpoint responds with 200 and the NDJSON content type. After that, a failure, such as a timeout, a stalled stream, or a decision outside the contract, reaches the user as an error event in the reply stream, after the progress lines and the reply text already sent.
In the TinyMCE AI plugin, a failed hook call shows the general chat error message, An error occurred while processing the AI response. For an error event, the message replaces the partial reply. The browser console shows more detail:
-
For
hook-failed(HTTP502), the console showsTinyMCE AI Error:followed by the error message, the status code, the trace ID, and the error code. -
For
hook-timeout(HTTP504), the console showsTinyMCE AI Error:followed byUnexpected status code: 504. -
For an
errorevent in the reply stream, the console showsTinyMCE AI Error:followed by the message of the event.
For a 502 or 504 response, read the requestId from the response body in the network panel of the browser developer tools.
When the user cancels
When the user stops the request before the endpoint responds, the AI service closes the connection to the endpoint at once, in both response modes, and the endpoint sees the request abort. The AI service logs a cancellation, not a failure, and stores no message for the turn.
Symptoms
| Symptom | Resolution |
|---|---|
The AI service does not start after setting |
The configuration breaks a rule in Options, for example an |
Every chat turn fails with |
The secret in |
The endpoint rejects every request with |
The endpoint verifies a re-serialized body, or uses a different secret. Verify the signature over the raw request body, and confirm that the endpoint and |
Every chat turn fails with |
Raise |
Chat turns fail with |
Redirects are not followed. Set |
Chat turns fail with |
The decision exceeds |
In |
The stream failed after the response headers: it stalled for longer than |
Every chat turn fails, while quick actions and reviews still work |
Hooks fail closed, and the hook endpoint is unavailable. Restore the endpoint, or set |