Custom Script Guardrails
A custom script guardrail runs screening logic you write inside the gateway. Use it when the service you want to screen against is not one of the built-in providers — an in-house classifier, a commercial product AISIX has no integration for, or a policy that combines several checks.
Every other guardrail kind speaks one provider's protocol. There is no industry standard for content-screening APIs, so reaching a service that speaks its own would otherwise mean building and operating a translation service in front of it. A custom script puts that translation inside the gateway instead, where it is a dozen lines of configuration rather than a deployment.
In this guide, you will write a script that screens prompts against your own service, attach it to a single model so you can try it safely, and verify that AISIX blocks a matching request before calling the upstream model.
Prerequisites
Before starting, prepare the following:
- Review Guardrail Behavior for hook points, enforcement modes, and failure policies.
- A screening service the gateway can reach. The script runs inside your gateway, so this can be a service on your private network.
- One of these configuration paths:
- AISIX Cloud with an environment, an attached gateway, and a write-scoped admin token. For On-Premises, follow the AISIX Cloud Quickstart. To request Hybrid Cloud access, contact API7.
- An open-source AISIX gateway that loads a declarative
resources.yamlfile.
- A working model alias and caller API key that can send Chat Completions requests.
curl. The AISIX Cloud path also usesjq.
Write a Script
A script is an ES module exporting checkInput, checkOutput, or both. A hook you do not export is skipped, so a script may cover one direction only.
export async function checkInput(ctx) {
const resp = await fetch("https://screening.internal/scan", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": ctx.secrets.SCAN_KEY,
},
body: JSON.stringify({ text: ctx.text }),
});
if (!resp.ok) {
throw new Error("screening service returned " + resp.status);
}
const body = await resp.json();
return body.outcome.deny
? { action: "block", reason_code: body.outcome.rule }
: { action: "none" };
}
Throwing, as this script does on a non-2xx response, hands the decision to the guardrail's failure policy rather than guessing. With the default policy that blocks the request, which is what a screening control should do when it cannot screen.
What the Hook Receives
| Field | Description |
|---|---|
ctx.hook | "input" or "output". |
ctx.text | Every text slot joined, for a script that screens the request as a whole. |
ctx.segments | The text slots as an array. A rewriting script returns one replacement per slot. |
ctx.messages | The slots with their roles, as {role, text}. |
ctx.model | The model the request addressed. Input hook only. |
ctx.secrets | The values configured on the guardrail, by name. |
What the Hook Returns
| Return value | Result |
|---|---|
{action: "none"} | Allowed. |
{action: "block", reason_code, reason} | Refused. Both fields go to your gateway logs; neither reaches the caller. |
{action: "mask", segments, counts} | Allowed with the text replaced. segments must have one entry per ctx.segments entry. |
Returning anything else — including nothing — is treated as a failure, not as an allow. A script that decided nothing must not read as a script that decided to permit.
What the Script Can Call
| API | Purpose |
|---|---|
fetch(url, init) | Call your service. Returns {status, ok, headers, text(), json()}. |
console.log/info/warn/error/debug | Write to your gateway's log. |
crypto.hmac(alg, key, data, outEncoding, keyEncoding) | HMAC-SHA1 or HMAC-SHA256, for a service that requires a signed request. |
crypto.hash(alg, data, outEncoding) | SHA-1 or SHA-256. |
crypto.base64Encode/base64Decode(data) | Base64. |
crypto.randomUUID() | A random UUID, for a nonce. |
aisix.embed(model, texts) | Embed text with an embedding model in the same environment, for screening by meaning. |
crypto.hmac accepts and returns "hex" or "base64", and reads its key as "utf8", "hex", or "base64". Reading a key as hex is what lets you express a derivation chain, where each step's raw output keys the next:
let k = crypto.hmac("sha256", "AWS4" + ctx.secrets.SECRET, date, "hex");
k = crypto.hmac("sha256", k, region, "hex", "hex");
There is no file system, no environment access, and no timer. A failed fetch or an unsupported algorithm throws, so your script can decide what to do about it.
Create a Custom Script Guardrail
Attach it to one model first, rather than the whole environment. A script is code you just wrote, and scoping it to a model you control means a mistake affects only the traffic you are testing with. See Debug a Script below.
AISIX Cloud
Create the guardrail with your script and the credentials it reads:
curl -X POST "$ADMIN_ENDPOINT/api/environments/$ENV_ID/guardrails" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "in-house-screening",
"enabled": true,
"kind": "custom",
"hook_point": "input",
"fail_open": false,
"config": {
"script": "export async function checkInput(ctx) { const r = await fetch(\"https://screening.internal/scan\", { method: \"POST\", headers: { \"content-type\": \"application/json\", \"x-api-key\": ctx.secrets.SCAN_KEY }, body: JSON.stringify({ text: ctx.text }) }); if (!r.ok) { throw new Error(\"screening returned \" + r.status); } const b = await r.json(); return b.outcome.deny ? { action: \"block\", reason_code: b.outcome.rule } : { action: \"none\" }; }",
"secrets": { "SCAN_KEY": "your-screening-service-key" },
"timeout_ms": 5000
}
}' | jq
Secrets are stored encrypted and are never returned. A later read shows their names so you can see what your script may read, but never their values.
Attach it to a single model:
curl -X POST "$ADMIN_ENDPOINT/api/environments/$ENV_ID/guardrail_attachments" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"guardrail_id": "'"$GUARDRAIL_ID"'", "scope_type": "model", "scope_id": "'"$MODEL_ID"'", "enabled": true}' | jq
You can also create and edit the guardrail in the AISIX Cloud dashboard, which provides an editor for the script and a name/value editor for its secrets.
Open-Source AISIX Gateway
Add the guardrail to your resources.yaml file. A block scalar keeps the script readable:
guardrails:
- name: in-house-screening
enabled: true
kind: custom
hook_point: input
fail_open: false
script: |
export async function checkInput(ctx) {
const resp = await fetch("https://screening.internal/scan", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": ctx.secrets.SCAN_KEY,
},
body: JSON.stringify({ text: ctx.text }),
});
if (!resp.ok) {
throw new Error("screening service returned " + resp.status);
}
const body = await resp.json();
return body.outcome.deny
? { action: "block", reason_code: body.outcome.rule }
: { action: "none" };
}
secrets:
SCAN_KEY: your-screening-service-key
timeout_ms: 5000
guardrail_attachments:
- guardrail_name: in-house-screening
scope_type: model
scope_id: your-model-alias
enabled: true
Reload the gateway with SIGHUP.
Verify the Guardrail
Send a request your screening service refuses:
curl -i "$GATEWAY_ENDPOINT/v1/chat/completions" \
-H "Authorization: Bearer $CALLER_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "your-model-alias", "messages": [{"role": "user", "content": "something your service denies"}]}'
AISIX answers 422 with a content_filter error and never calls the upstream model. An unrelated request returns the model's answer as usual.
Debug a Script
There is no separate console for trying a script out. Debug it the way you would debug a route: against a model that only you are using.
- Create a model you can throw away. Point it at the same provider as your real model, with an alias like
screening-test. - Attach the guardrail to that model only, with
scope_type: modelas above. Nothing else in the environment is affected. - Send requests to that alias and read your gateway's logs.
console.logfrom your script appears there, so you can print what your service returned and what your script decided. - Widen the scope once it behaves. Reattach the guardrail to the environment, or to the models you actually want screened, and delete the test model.
Two things to check first when a script does not behave:
- A syntax error is refused at save time, with the line and column. If your guardrail saved successfully, the script parses.
- A script that fails at runtime shows up in the gateway log and in the request's usage event, under a reason that says which kind of failure it was:
custom_timeoutfor a script that ran out of its budget,custom_script_errorfor one that threw,custom_bad_verdictfor one that returned something that is not a verdict.
Screen Model Responses
Export checkOutput and set hook_point to output or both. The response hook receives the model's reply in the same shape.
For a streamed response, AISIX releases content in windows: each window is screened, and released once it passes. This keeps a streaming client responsive rather than holding the whole answer back. Set stream_processing_mode to buffer_full if you would rather hold the entire response and screen it once.
Rewrite Content
Return {action: "mask", segments} with one replacement per entry in ctx.segments:
export function checkInput(ctx) {
const masked = ctx.segments.map((s) => s.replace(/\d{3}-\d{2}-\d{4}/g, "<SSN>"));
const changed = masked.filter((s, i) => s !== ctx.segments[i]).length;
if (changed === 0) return { action: "none" };
return { action: "mask", segments: masked, counts: { US_SSN: changed } };
}
counts is optional and records what you detected, by name, for the request's usage event. Record names only — recording matched content would put it in your telemetry.
Where AISIX cannot substitute the text back, a rewrite request blocks rather than releasing the original, so a policy is never half-applied.
Limits and Failure Behavior
| Setting | Default | Description |
|---|---|---|
timeout_ms | 5000 | Budget for one hook invocation, covering the script and every call it makes. Set per-call timeouts in the script itself. |
max_memory_bytes | 16777216 | Memory ceiling for the script. One response body may use a quarter of it, since the body is parsed inside the same memory. |
fail_open | false | What happens to a request the script could not screen. |
output_fail_open | false | The same, for a response. |
The budget covers two kinds of runaway independently: a script stuck in a loop is interrupted, and a script waiting on a call that never answers is abandoned. Either way the failure policy decides the verdict.
Each invocation runs in a fresh sandbox, so nothing a script stores survives to the next request. Screening state that has to persist belongs in your own service.
Next Steps
- Guardrail Behavior — hook points, enforcement modes, and failure policies in detail.
- Semantic Screening — built-in screening by meaning, if that is all you need.
- Guardrails Overview — the full list of built-in providers.