Skip to main content
Version: Dev

Custom Script Guardrails

A custom script guardrail runs screening logic you write inside the gateway. Use it for an in-house classifier, a commercial product AISIX has no integration for, or a policy that combines several checks.

Remote guardrail integrations speak a specific provider's protocol. There is no industry standard for content-screening APIs, so reaching another service would otherwise require an adapter that translates between AISIX and that service. A custom script puts the translation inside the gateway.

You can configure custom script guardrails through AISIX Cloud or in an open-source AISIX gateway's resources.yaml file. In this guide, you will write a script, limit its initial scope, 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.yaml file.
  • A working model alias and caller API key that can send Chat Completions requests.
  • curl. The AISIX Cloud path also uses jq.

Export the gateway values used by the verification request:

# AISIX_PROXY has no trailing slash or endpoint path
# The local quickstarts use http://127.0.0.1:3000
export AISIX_PROXY="YOUR_AISIX_GATEWAY_ORIGIN"
export AISIX_API_KEY="YOUR_CALLER_API_KEY"
export AISIX_MODEL="YOUR_MODEL_ALIAS"
# Used in the Cloud create request or passed to the open-source gateway process.
export SCREENING_SERVICE_KEY="YOUR_SCREENING_SERVICE_KEY"

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. The default policy blocks a request that the script cannot screen.

What the Hook Receives

FieldDescription
ctx.hook"input" or "output".
ctx.textEvery text slot joined, for a script that screens the request as a whole.
ctx.segmentsThe text slots as an array. A rewriting script returns one replacement per slot.
ctx.messagesThe slots with their roles, as {role, text}.
ctx.modelThe model the request addressed. Input hook only.
ctx.secretsThe values configured on the guardrail, by name.

What the Hook Returns

Return valueResult
{action: "none"}Allowed.
{action: "block", reason_code, reason}Refused. Both fields go to your gateway logs; neither reaches the caller.
{action: "mask", segments}Allowed with the text replaced. segments must have one entry per ctx.segments entry. On gateways with the safe telemetry behavior described below, an optional legacy counts object is accepted but ignored.

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

APIPurpose
fetch(url, init)Call your service. Returns {status, ok, headers, text(), json()}.
console.log/info/warn/error/debugWrite 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 available in the same AISIX Cloud environment or resources-file configuration.

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

Choose one configuration path. In either path, attach the guardrail to one test model before sending traffic.

AISIX Cloud

Export the control-plane connection details and the ID of the test model:

# AISIX_CP includes /api and has no trailing slash
# The local On-Premises quickstart uses http://localhost:8080/api
export AISIX_CP="YOUR_AISIX_CLOUD_ADMIN_API_BASE_URL"
export AISIX_TOKEN="YOUR_ADMIN_TOKEN"
export ENV_ID="YOUR_ENVIRONMENT_ID"
export MODEL_ID="YOUR_TEST_MODEL_ID"

Create the guardrail disabled and capture its ID. Building the request with jq preserves the multi-line script and safely encodes the screening-service credential:

CUSTOM_SCRIPT=$(cat <<'EOF'
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" };
}
EOF
)

CUSTOM_PAYLOAD=$(
jq -n \
--arg script "$CUSTOM_SCRIPT" \
--arg scanKey "$SCREENING_SERVICE_KEY" \
'{
name: "in-house-screening",
enabled: false,
kind: "custom",
hook_point: "input",
fail_open: false,
config: {
script: $script,
secrets: {SCAN_KEY: $scanKey},
timeout_ms: 5000
}
}'
)

GUARDRAIL_RESPONSE=$(printf '%s\n' "$CUSTOM_PAYLOAD" | \
curl --fail-with-body -sS -X POST "$AISIX_CP/environments/$ENV_ID/guardrails" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @-)
export GUARDRAIL_ID=$(printf '%s\n' "$GUARDRAIL_RESPONSE" | jq -er '.guardrail.id')

AISIX Cloud validates the script when the guardrail is created or updated. It encrypts secret values at rest and never returns them; read operations expose only the secret names.

Attach the guardrail to the test model, then enable it. The && prevents an attachment failure from enabling an unscoped guardrail:

curl --fail-with-body -sS -X POST "$AISIX_CP/environments/$ENV_ID/guardrails/$GUARDRAIL_ID/attachments" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"scope_type\": \"model\",
\"scope_id\": \"${MODEL_ID}\"
}" && \
curl --fail-with-body -sS -X PATCH "$AISIX_CP/environments/$ENV_ID/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"enabled": true
}'

The AISIX Cloud dashboard provides a script editor, a name/value editor for secrets, and scope selection. Use the Admin API sequence above when the guardrail must remain disabled until its attachment succeeds.

Open-Source AISIX Gateway

Add the following guardrail and model attachment to the gateway's resources file. Set scope_id to the same model alias you exported as AISIX_MODEL.

If guardrails or guardrail_attachments already exists, add the entry to the existing collection instead of creating a duplicate top-level key. The block scalar keeps the script readable, while environment interpolation keeps the screening-service credential out of the file.

resources.yaml (custom script guardrail)
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: ${SCREENING_SERVICE_KEY}
timeout_ms: 5000

guardrail_attachments:
- guardrail_id: in-house-screening
scope_type: model
scope_id: YOUR_MODEL_ALIAS
priority: 100

The model attachment limits the guardrail to that alias. Without an attachment, the guardrail loads but inspects no traffic.

Validate the complete file in a short-lived container before applying it.

SCREENING_SERVICE_KEY is new to the gateway process, so a running container cannot inherit the host export. Run the command below from the directory containing resources.yaml. It assumes the quickstart variables OPENAI_API_KEY and CALLER_API_KEY; replace or add -e entries so the container receives every environment variable referenced by your file.

docker run --rm \
-v "$(pwd):/etc/aisix:ro" \
-e OPENAI_API_KEY \
-e CALLER_API_KEY \
-e SCREENING_SERVICE_KEY \
--entrypoint /usr/local/bin/aisix \
ghcr.io/api7/aisix:dev \
validate --resources /etc/aisix/resources.yaml

After validation, recreate the gateway as shown in Add New Environment Variables. Substitute ghcr.io/api7/aisix:dev for the image in that workflow as well.

aisix validate checks the resource shape and builds every enabled guardrail. It exits nonzero if the custom script cannot compile, so apply the file only after validation succeeds.

Verify the Guardrail

Send a request your screening service refuses:

curl -i "$AISIX_PROXY/v1/chat/completions" \
-H "Authorization: Bearer $AISIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "'"$AISIX_MODEL"'",
"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. Keep the guardrail attached to one test model until its allow, block, and failure paths behave as intended.

  1. Prepare a test model alias.
  2. Attach the guardrail only to that model, whether you configure it through AISIX Cloud or resources.yaml.
  3. Send requests to the test alias and read the gateway logs. Output from the script's console functions appears there, so you can inspect what the service returned and what the script decided.
  4. Widen the attachment or add other attachments only after the allow, block, and failure cases behave as intended.

Two things to check first when a script does not behave:

  • Catch syntax errors before deployment. AISIX Cloud rejects a syntax error when the guardrail is saved. For a resources file, aisix validate builds enabled guardrails and exits nonzero if the script cannot compile.
  • A script that fails at runtime appears in the gateway log and the request's usage event with a reason code identifying the failure:
    • custom_timeout — the script exceeded its execution budget.
    • custom_script_error — the script threw an exception.
    • custom_unknown_action — the returned action is not supported.
    • custom_no_verdict — the hook returned no decision or omitted action.
    • custom_bad_verdict — the verdict has the wrong structure or type, or a mask returned missing or misaligned segments.
    • custom_engine_error — AISIX could not start the script engine or install its host functions.

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 };
}

Gateways with this behavior derive the usage-event count from the returned segments: the fixed name is custom, and its value is the number of segments whose replacement differs from the original. A counts object returned by a script is accepted but ignored. Scripts cannot choose telemetry names or values because both can otherwise expose request content or ctx.secrets. AISIX Cloud stores usage detail verbatim, so records created by older gateway versions can retain legacy script-provided counts; upgrading does not rewrite them.

If AISIX cannot replace the original text with the returned segments, it blocks rather than releasing the unmodified content.

Limits and Failure Behavior

SettingDefaultDescription
timeout_ms5000Wall-clock budget for one hook invocation, covering the script and every call it makes. The script API has no separate timer or per-fetch timeout.
max_memory_bytes16777216Memory ceiling for the script. One response body may use a quarter of it, since the body is parsed inside the same memory.
fail_openfalseWhether to allow a request that the script cannot screen. The default blocks the request.
output_fail_openfalseWhether to release a response that the script cannot screen. The default blocks the response.

The budget covers both a script stuck in a loop and a script waiting on a call that never answers. When the budget expires, 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