Hooks let you plug custom logic into an agent’s runtime pipeline. At defined lifecycle events — when a message arrives, before/after the LLM call, when a file is ingested or about to be uploaded — a hook can inspect, modify, allow or block the payload by calling an external endpoint, a platform-provided automation, or by evaluating a declarative policy in the browser.
Typical uses: PII redaction, toxicity / classification blocking, citation enforcement, file MIME or watermark checks, antivirus scanning, and the client-side file-classification gate (block a document before it ever leaves the browser).
Hooks exist at three levels — agent, org, and platform. They are merged (agent ∪ org ∪ platform). The agent UI manages the agent’s own hooks and shows org/platform hooks as read-only. An org/platform hook can be marked immutable so it always applies and cannot be disabled or overridden below.
How a hook works
For a server-side hook (http / workspace target) the platform sends your endpoint a JSON request describing the event, and your endpoint answers with a decision that the pipeline applies:
┌─────────────────────────┐
lifecycle ───► │ POST { event, content } │ ──► your hook endpoint
event └─────────────────────────┘
◄── { decision, ... } ◄──────── (allow / deny / transform / regenerate)
The two contracts that matter most are therefore what your hook receives and what your hook must answer — both documented in full below.
Lifecycle events
A hook subscribes to one or more events:
| Event | When it fires | Where | Typical use |
|---|
before_upload | In the browser, before a file is uploaded | client | Client-side file-classification gate (see below) |
before_file_ingest | Before a file enters the indexing pipeline | server | MIME/type allow-list, size cap, antivirus |
after_file_parse | After a file has been parsed to text, before chunking/embedding | server | Watermark / sensitivity-marking detection, redaction |
before_message | Before a user message enters the pipeline | server | PII redaction, input filtering |
before_llm | Just before the LLM call | server | Prompt inspection, external-agent proxy (stream) |
after_llm | After the LLM response, before it reaches the user | server | Toxicity blocking, citation enforcement, output rewriting |
Targets
The target.type decides where the hook runs.
http — call your own endpoint
The hook POSTs the event payload to a URL and uses the response to allow / modify / block. The endpoint is most often a Builder automation exposed as a webhook, so you stay inside Prisme.ai (RBAC, secrets, audit) — this is the recommended way to run your own hook logic.
{
"id": "pii-redact",
"events": ["before_message"],
"mode": "sync",
"target": {
"type": "http",
"url": "https://api.<domain>/v2/workspaces/slug:my-ws/webhooks/hook-pii-redact"
},
"on_error": "allow",
"enabled": true
}
The request carries only Content-Type: application/json (plus the platform’s own x-prismeai-workspace-id). Custom request headers and per-request auth are not forwarded — put any secret the endpoint needs in the workspace itself (Builder secrets / config), or in a non-guessable path segment of the URL.
workspace — call an automation by slug
Reference a Builder automation by workspace_slug + automation_slug instead of a full URL — the platform builds the webhook call for you and sends the same { event, content, callback?, options? } body as http. The target automation must declare when: { endpoint: true }.
{
"target": {
"type": "workspace",
"workspace_slug": "my-ws",
"automation_slug": "scan-document",
"options": { "threshold": 0.8 }
}
}
target.options (optional) is forwarded verbatim so a single automation can serve several hooks with different static config — read it as {{body.options}}.
workspace targets fail closed: if the target returns HTTP ≥ 400, or a body with no decision (e.g. the automation doesn’t exist or is missing endpoint: true), the hook resolves to an error and your on_error policy applies — so on_error: "deny" really does block. A handful of platform test slugs (_mock-hook-*) are dispatched internally rather than over HTTP.
client — declarative browser policy (file gate)
A client hook is evaluated in the browser against a file’s extracted metadata, before any upload. No external call, no third-party JavaScript — see The client-side file gate. For client targets mode is ignored (it is always a synchronous, in-browser check) and the only valid event is before_upload.
Execution modes
mode controls the server-side timing of http / workspace hooks. It is ignored for client targets.
| Mode | Behavior | Valid for |
|---|
sync | The pipeline waits for the hook and applies its decision (allow / deny / transform / regenerate). Use for anything that must block or rewrite. | all server events |
async | Fire-and-forget — the request continues immediately; your endpoint calls back later to complete the task. Use for long-running scans. | before_file_ingest only |
stream | Your endpoint takes over the LLM step and streams the response back (external-agent proxy). | before_llm only |
async and stream are advanced modes with a callback contract — see Async & stream callbacks. sync is what the vast majority of hooks use.
What your hook receives
Every server-side hook is a POST with this body:
{
"event": "before_message",
"content": { /* event-specific, see below */ },
"callback": { /* only for async / stream modes */ }
}
When the target is a Builder automation, the whole POST body is available as {{body}} — so you read {{body.event}}, {{body.content.text}}, {{body.callback.token}}, etc.
The content object depends on the event:
| Event | content fields |
|---|
before_message | text (the user message), parts (raw A2A parts), context_id, agent_id, user_id |
before_llm | messages (full array bound for the LLM), tools, model, context_id, task_id, agent_id |
after_llm | text (the model’s generated answer), sources (RAG citations — present only when the agent retrieved knowledge), context_id, task_id, agent_id |
before_file_ingest | file_id, filename, mime_type, size, url, agent_id, conversation_id |
after_file_parse | file_id, vector_store_id, metadata, page_count, char_count, payload_mode, and either text (when payload_mode: "inline") or signed_url (when payload_mode: "reference") |
For after_file_parse, large documents (over ~256 KB of text) are sent by reference: content.payload_mode is "reference" and you get a content.signed_url to fetch instead of inline content.text. Handle both — a hook that only reads content.text will see nothing on large files.
What your hook must answer
A sync hook must return a decision object:
{
"decision": "allow | deny | transform | regenerate",
"reason": "string — shown to the user only if expose_reason is set (deny)",
"transformed": {
"text": "replacement text",
"metadata": { "any": "extra fields, merged into content.metadata" }
},
"regeneration_guidance": "string — instructions for the retry (regenerate)",
"score": 0.99,
"tags": ["your", "labels"]
}
decision | Effect |
|---|
allow (or an omitted decision) | The payload passes through unchanged. |
deny | The payload is blocked. The user sees a generic message unless the hook has expose_reason: true, in which case your reason is appended. The blocking hook’s id is recorded in the response metadata as blocked_by. |
transform | The payload is rewritten with transformed.text. For before_message this changes the text sent onward to the LLM; for after_llm it replaces the final answer shown to the user. transformed.metadata is merged into the content metadata. |
regenerate | (after_llm) The agent re-runs the LLM using regeneration_guidance as extra instruction. |
score and tags are optional — they are recorded on the hook.invoked analytics event but do not by themselves change the outcome.
Decision precedence
When several hooks fire on the same event, their decisions are merged with this precedence:
deny > regenerate > transform > allow
A single deny blocks the payload regardless of what the other hooks returned.
Signalling an error
If your endpoint cannot make a decision, return an object with an error and no decision:
{ "error": "scanner_unavailable" }
The platform then applies the hook’s on_error policy (allow = fail-open, deny = fail-closed). A thrown HTTP error, a non-JSON response, or a timeout is treated the same way.
Examples
A before_message PII gate that blocks or redacts:
# Builder automation exposed as a webhook
- conditions:
'{{body.content.text}} matches regex("/\\b\\d{16}\\b/")':
- set:
name: output
value:
decision: deny
reason: A card number was detected in your message.
tags: [pii, card]
default:
- set:
name: output
value:
decision: allow
- output: '{{output}}'
An after_llm guard that enforces citations:
{ "decision": "regenerate", "regeneration_guidance": "Answer again and cite at least one source." }
Behavior controls
| Field | Description | Default |
|---|
enabled | Turn the hook on/off | true |
on_error | What happens if the hook call fails or times out: allow (fail-open) or deny (fail-closed) | allow |
expose_reason | Surface the hook’s reason to the end user on a deny (otherwise a generic message) | false |
rate_limit_per_min | Max hook invocations per minute (per org, per hook) | 1000 |
display_name | Human-readable label in the UI (falls back to id) | — |
Choose on_error deliberately. For a security control (block forbidden files/content) use on_error: "deny" so a failing or unreachable hook never silently lets the payload through. For a best-effort enrichment use allow.
Error handling & resilience
Hooks are designed to never take the agent down:
on_error policy — every failure (timeout, error response, { "error": ... } body) resolves to allow or deny per the hook’s setting.
- Circuit breaker — if a hook fails repeatedly (5 failures within 60s) it is temporarily skipped for ~60s and its
on_error policy applies to skipped calls. It resets on the next success.
- Rate limiting — beyond
rate_limit_per_min, further calls are skipped (again honouring on_error).
Every invocation emits a hook.invoked event (with decision, duration_ms, score, tags, error) that you can inspect in the activity feed / analytics.
Async & stream callbacks
async and stream hooks respond in two phases: an immediate acknowledgement, then a callback into the task. The request body carries a callback object:
"callback": {
"url": "…/v2/workspaces/slug:agent-factory/webhooks/v1/agents/<agent>/tasks/<task>/events",
"patch_url": "…/v2/workspaces/slug:agent-factory/webhooks/v1/agents/<agent>/tasks/<task>",
"token": "cbt_…",
"expires_at": 1783600000000
}
Authenticate every callback with the token in an X-Hook-Token header (an Authorization: Bearer <token> header is also accepted).
async (before_file_ingest)
Acknowledge immediately, then finish the task when your scan completes:
do:
- wait: { timeout: 5 } # your long-running scan
- fetch:
url: '{{body.callback.patch_url}}'
method: PATCH
headers:
X-Hook-Token: '{{body.callback.token}}'
Content-Type: application/json
body:
status: { state: completed } # or { state: failed, message: "…" }
output:
accepted: true # returned immediately to unblock the request
The token defaults to a 24h TTL for async.
stream (before_llm)
Your endpoint takes over the LLM step and pushes events to callback.url:
# emit incremental chunks…
- fetch:
url: '{{body.callback.url}}'
method: POST
headers: { X-Hook-Token: '{{body.callback.token}}', Content-Type: application/json }
body:
event: task.output.delta
data: { part: { text: 'Hello ' } }
# …then a single completion event
- fetch:
url: '{{body.callback.url}}'
method: POST
headers: { X-Hook-Token: '{{body.callback.token}}', Content-Type: application/json }
body:
event: task.output.completed
data:
output:
messages:
- role: agent
parts: [ { text: 'Hello from the external agent.' } ]
The agent waits for the first event (default ~2s first-byte timeout) up to a total ~60s, then uses the completed output as the LLM response. The stream token defaults to a 5-minute TTL.
The client-side file gate
The most common client hook is a file-classification gate: read a document’s metadata locally and reject non-conformant files before they ever leave the browser (e.g. a document classified above the level your SaaS is allowed to process).
{
"id": "titus-classification-gate",
"events": ["before_upload"],
"target": {
"type": "client",
"policy": [
{
"property": "custom.Classification",
"operator": "in",
"allowed": ["C0", "C1"],
"on_fail": "deny",
"message": "This document is classified above the allowed level. Upload blocked."
}
]
},
"enabled": true
}
Policy rules
Rules are evaluated with AND semantics — the first rule the file does not satisfy whose on_fail is deny blocks the upload.
operator | Conforms when… |
|---|
in | the value is in allowed |
not_in | the value is not in allowed (a missing value conforms — fail-open) |
equals | the value equals allowed[0] |
exists | the property is present and non-empty |
matches | the value matches one of the allowed regular expressions |
in / equals / matches / exists are fail-closed: a missing property fails the rule, so an unmarked document is blocked by a deny rule. not_in is fail-open (nothing to forbid when the property is absent) — pair it with an exists rule to also block unmarked files.
The extractor flattens each file’s metadata into namespaced keys you target with property:
| Prefix | Source | Examples |
|---|
file.* | Always present (the File object) | file.name, file.type, file.size |
custom.* | Office docProps/custom.xml (where classification tools like TITUS write markings), and ODF meta.xml user-defined fields (.odt/.ods/.odp, incl. LibreOffice TSCP/BAILS) | custom.Classification, custom.TitusGUID, custom.urn:bails:IntellectualProperty:BusinessAuthorizationCategory:Name |
core.* / app.* | Office core / app properties | core.title, app.Company |
info.* / xmp.* | PDF info dictionary / XMP | info.Subject, info.Keywords |
email.* | .eml RFC 5322 headers | email.X-TITUS-Classification |
mip:label:* | Microsoft Purview / MIP sensitivity labels, normalized (see below) | mip:label:id, mip:label:name |
bj:label:* | Boldon James / Fortra Classifier SISL labels, one key per label element uid | bj:label:id_classification_internal |
Microsoft Purview / MIP sensitivity labels
MIP stores its label differently depending on the Office version and the file type — in the docMetadata/LabelInfo.xml part (modern Office), as MSIP_Label_<guid>_* custom properties (legacy Office, PDF via Acrobat), or packed into a single msip_labels header (Outlook .eml). The extractor normalizes all of them into the same flat keys, which also match what the server-side crawler (Tika) emits — so one rule works everywhere:
| Key | Value |
|---|
mip:label:id | Label GUID, without braces (e.g. f0445a89-d0ab-49cd-a6c6-1e21e5d4c192) |
mip:label:name | Label name, when present (legacy properties only) |
mip:label:enabled | 1 / true |
mip:label:method | Standard (auto/default) or Privileged (manually selected) |
mip:label:siteId | Microsoft Entra tenant GUID, without braces |
mip:label:contentBits | Marking bitmask (header/footer/watermark/encrypt), when present |
A typical gate — only documents carrying one of the approved labels may be uploaded:
{
"property": "mip:label:id",
"operator": "in",
"allowed": ["f0445a89-d0ab-49cd-a6c6-1e21e5d4c192"],
"on_fail": "deny",
"message": "Only documents labeled 'Public' or 'General' can be uploaded."
}
The label GUID is the id of the label in your Microsoft Purview compliance portal; you can also read it from a labeled document (unzip -p doc.docx docMetadata/LabelInfo.xml) or from the crawler’s extracted metadata (mip:label:id). Removed (removed="1") and disabled labels are ignored.
Files whose metadata cannot be read in the browser — label-encrypted Office files (the label encrypts the container), Outlook .msg, .pfile — yield an empty metadata bag, so a fail-closed deny rule blocks them. That is the safe default: a document that cannot prove conformance does not go through.
The client gate is a fast-fail UX convenience, not an authoritative barrier — it runs in the user’s browser. Always pair a classification policy with a server-side before_file_ingest hook (target: http or workspace) that re-checks the file, so the rule is enforced even if the browser is bypassed.
Hooks defined at the org or platform level apply to every agent in scope. Set immutable: true to make a policy mandatory: it always applies and cannot be disabled or overridden by an agent. In the agent UI these appear as read-only / locked, with locked_by indicating org or platform. Only org/platform configs may set immutable — an agent-level hook that tries to is rejected.
Limits
- Up to 10 hooks per agent.
- Unique
id per hook; each hook must declare at least one event.
- Default rate limit: 1000 invocations/minute per hook.
http targets must use https:// and may not point at internal/private addresses.
Configuring hooks
Hooks are managed from the agent’s Capabilities page (see Capabilities). Add a hook, pick its event(s), choose the target (HTTP / workspace / client) and mode, then set the behavior controls. For HTTP targets, point it at a Builder automation exposed as a webhook and implement the request / response contract above.