# 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 the AISIX Cloud Admin API 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[​](#prerequisites "Direct link to Prerequisites")

Before starting, prepare the following:

* Review [Guardrail Behavior](https://docs.api7.ai/ai-gateway/traffic-controls/guardrails/behavior.md) 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 Enterprise or AISIX Cloud with an environment, an attached gateway, and a write-scoped admin token. For AISIX Enterprise, follow the [AISIX Enterprise getting-started guide](https://docs.api7.ai/ai-gateway/getting-started/aisix-cloud-quickstart.md). To request AISIX Cloud access, [contact API7](https://api7.ai/contact).
  * An open-source AISIX gateway that loads a declarative [`resources.yaml`](https://docs.api7.ai/ai-gateway/reference/resources-file.md) file.

* A working model alias and caller API key that can send Chat Completions requests.

* `curl`. The control-plane 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_URL"

export AISIX_API_KEY="YOUR_CALLER_API_KEY"

export AISIX_MODEL="YOUR_MODEL_ALIAS"

# Used in the AISIX Cloud Admin API request or passed to the open-source AISIX gateway process.

export SCREENING_SERVICE_KEY="YOUR_SCREENING_SERVICE_KEY"
```

## Write a Script[​](#write-a-script "Direct link to 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[​](#what-the-hook-receives "Direct link to 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. It can be empty on the output hook, as described below.                                                                                                                                                                    |
| `ctx.messages` | The slots as `{role, text}`. On `/v1/chat/completions`, `/v1/responses`, and `/v1/messages`, a custom guardrail runs on the segment pass, where every entry's `role` is `"user"` on both hooks, whatever role the text had in the request. Do not use `role` to tell system, assistant, or tool text apart. |
| `ctx.model`    | On the input hook of a model request, the caller-addressed alias. For a routing, semantic routing, or ensemble model, this is the group alias rather than the selected member or upstream model. It is absent on the output hook and on model-less requests such as MCP tool calls.                         |
| `ctx.secrets`  | The values configured on the guardrail, by name.                                                                                                                                                                                                                                                            |

On the output hook, the script also runs on a response with no text, such as an image-only answer, an empty stream, or an empty transcript. `ctx.segments` is then empty. A script that blocks unconditionally refuses such a response, and one in monitor mode records its verdict. A script that reads `ctx.segments[0]` must handle the empty case, because a script that throws on a fail-closed guardrail refuses the response. A stream the upstream failed with no text does not run the script.

### What the Hook Returns[​](#what-the-hook-returns "Direct link to 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}`             | 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[​](#what-the-script-can-call "Direct link to 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. `console.log` and `console.info` write at `info`, so they need `log_level: "info"`; the others write at their own level. |
| `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 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[​](#create-a-custom-script-guardrail "Direct link to Create a Custom Script Guardrail")

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

### AISIX Enterprise or AISIX Cloud[​](#aisix-enterprise-or-aisix-cloud "Direct link to AISIX Enterprise or 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 AISIX Enterprise quickstart uses http://localhost:8080/api

export AISIX_CP="YOUR_AISIX_CONTROL_PLANE_API_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')
```

The AISIX Cloud Admin API 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 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.

### AISIX OSS[​](#aisix-oss "Direct link to AISIX OSS")

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:1.10.0 \

  validate --resources /etc/aisix/resources.yaml
```

After validation, recreate the gateway as shown in [Add New Environment Variables](https://docs.api7.ai/ai-gateway/deployment/configuration-propagation.md#add-new-environment-variables). Substitute `ghcr.io/api7/aisix:1.10.0` 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[​](#verify-the-guardrail "Direct link to 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[​](#debug-a-script "Direct link to Debug a Script")

There is no separate dashboard tool 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 the AISIX Cloud Admin API 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.** The AISIX Cloud Admin API 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[​](#screen-model-responses "Direct link to 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. `/v1/messages` and `/v1/responses` cannot release a stream by window, so on those routes AISIX holds the whole response and delivers it after the script screens it, in either mode. See [Window Mode on Messages and Responses](https://docs.api7.ai/ai-gateway/traffic-controls/guardrails/behavior.md#window-mode-on-messages-and-responses).

## Rewrite Content[​](#rewrite-content "Direct link to 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`. The control plane 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[​](#limits-and-failure-behavior "Direct link to Limits and Failure Behavior")

| Setting            | Default    | Description                                                                                                                                          |
| ------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `timeout_ms`       | `5000`     | Wall-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_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`    | Whether to allow a request that the script cannot screen. The default blocks the request.                                                            |
| `output_fail_open` | `false`    | Whether 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[​](#next-steps "Direct link to Next Steps")

* [Guardrail Behavior](https://docs.api7.ai/ai-gateway/traffic-controls/guardrails/behavior.md) — hook points, enforcement modes, and failure policies in detail.
* [Qwen3Guard Guardrails](https://docs.api7.ai/ai-gateway/traffic-controls/guardrails/qwen3guard.md) — a worked script for an open-source guard model you run yourself.
* [TypeSafe Jev Guardrails](https://docs.api7.ai/ai-gateway/traffic-controls/guardrails/typesafe-jev.md) — a worked script for a hosted decision model that screens requests.
* [Semantic Screening](https://docs.api7.ai/ai-gateway/traffic-controls/guardrails/semantic-screening.md) — built-in screening by meaning, if that is all you need.
* [Guardrails Overview](https://docs.api7.ai/ai-gateway/traffic-controls/guardrails/overview.md) — compare the available guardrail options.
