# Quickstart: Write a Custom Policy

The AI Gateway's built-in policies cover model access, budgets, caching,
guardrails, and tracing—but your gateway can run any policy you can write in
TypeScript. A custom policy lives in your gateway's
[repository](./source-control.mdx), is declared in `config/policies.json`, and
from then on appears in the portal's **Add Policy** dialog like any built-in
policy. Apps add it to their [policy chains](./policy-chains.mdx), and it runs
on every request for those apps.

This quickstart builds a content filter that blocks prompts containing banned
terms. By the end, one app on your gateway rejects a prompt containing
"acme-secret-project" with a `400` response, while other apps are unaffected.

## Prerequisites

- An AI Gateway project connected to a Git repository, with a provider, a team,
  and an app—the [Getting Started](./getting-started.mdx) guide covers this
- A local clone of the gateway's repository

<Stepper>

1. **Write the policy module**

   In your clone of the gateway repository, create a `modules/` directory next
   to `config/` if it doesn't exist yet, and add `modules/content-filter.ts`. A
   policy is a function that receives the request, a context, and the options
   configured for it—and returns the request to continue the chain, or a
   `Response` to answer immediately:

   ```ts title="modules/content-filter.ts"
   import type { ZuploContext, ZuploRequest } from "@zuplo/runtime";

   interface ContentFilterOptions {
     blockedTerms: string[];
   }

   export default async function contentFilter(
     request: ZuploRequest,
     context: ZuploContext,
     options: ContentFilterOptions,
   ): Promise<ZuploRequest | Response> {
     const body = await request.clone().json();
     const text = JSON.stringify(body.messages ?? body.input ?? "");

     const match = options.blockedTerms.find((term) =>
       text.toLowerCase().includes(term.toLowerCase()),
     );

     if (match) {
       context.log.warn(
         `Blocked request from app ${request.user?.sub}: matched "${match}"`,
       );
       return new Response(
         JSON.stringify({
           error: {
             message: "This request was blocked by your content policy.",
             type: "invalid_request_error",
           },
         }),
         { status: 400, headers: { "content-type": "application/json" } },
       );
     }

     return request;
   }
   ```

   The calling app is available on `request.user` when the request's API key
   resolves to one—`sub` is the app's name—so a policy can log, branch, or
   report per app. It can be `undefined`, so use optional chaining. The example
   inspects chat and response payloads; embeddings requests pass through
   unfiltered.

2. **Declare the policy in `config/policies.json`**

   The repository already contains `config/policies.json` with the built-in
   policy declarations. Add one more entry to its `policies` array—the entry's
   `name` is how the policy appears in the portal. The declaration makes the
   policy available for apps to select; it doesn't run for any app yet:

   ```json title="config/policies.json (one entry in the policies array)"
   {
     "name": "content-filter",
     "policyType": "custom-code-inbound",
     "handler": {
       "export": "default",
       "module": "$import(./modules/content-filter)",
       "options": {
         "blockedTerms": ["acme-secret-project"]
       }
     }
   }
   ```

3. **Deploy**

   Commit both files and push to your default branch:

   ```bash
   git add modules/content-filter.ts config/policies.json
   git commit -m "Add content-filter policy"
   git push
   ```

   The next production deploy puts the new policy on the menu—with GitHub, the
   push itself deploys; see [Source Control](./source-control.mdx) for the other
   Git providers.

4. **Add the policy to an app's chain**

   In the Zuplo Portal, open
   [**Apps**](https://portal.zuplo.com/+/account/project/ai/apps), select your
   app, and open its **Policies** tab. Click **Add Policy**—`content-filter` now
   appears alongside the built-in policies. Add it, drag it to where in the
   chain it should run, and save.

   :::tip{title="Where in the chain?"}

   If the chain has the Budgets and Costs policy, placing the filter before it
   means blocked requests aren't counted against the app's budget; placing it
   after means they are. The same reasoning applies to any policy that can
   answer a request itself.

   :::

   The change applies within about a minute—no deploy.

5. **Test it**

   Send a prompt containing a blocked term through the app, using the URL and
   API key from the app page (the URL below is a stand-in for it):

   ```bash
   curl https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1/chat/completions \
     -H "Authorization: Bearer YOUR_APP_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "model": "openai/gpt-5-mini",
       "messages": [{"role": "user", "content": "Tell me about acme-secret-project"}]
     }'
   ```

   The gateway answers with the policy's `400` response. Send a harmless prompt
   and the request flows through to the provider as usual. Apps that don't
   include `content-filter` in their chains are unaffected.

</Stepper>

## Per-app settings

An app's chain entry can override the declared options completely. In the
portal, edit the entry's options to give one app its own list:

```json
{
  "blockedTerms": ["acme-secret-project", "codename-falcon"]
}
```

An entry that overrides options replaces the declaration's entire options object
—fields aren't merged. An entry without options inherits the declaration's
options exactly.

## Configure credentials

If a policy needs a credential—say it calls an external moderation API—set it in
the declaration's options, and let chain entries inherit it:

```json title="config/policies.json"
{
  "name": "my-moderation-policy",
  "policyType": "custom-code-inbound",
  "handler": {
    "export": "default",
    "module": "$import(./modules/my-moderation-policy)",
    "options": {
      "apiKey": "your-moderation-api-key"
    }
  }
}
```

Leave the chain entry's options out so it inherits the declaration's values—an
entry that sets its own options replaces them completely.

## Beyond filtering

A custom policy can do more than block requests—the cookbooks walk through
complete recipes:

- **Route models dynamically.** Read the live model catalog and select the model
  per request—for example, always the cheapest active model. See
  [Dynamic model routing](./cookbooks/dynamic-model-routing.mdx).
- **Choose fallbacks from code.** Enrich the model selection with a dynamically
  chosen backup. See [Custom fallback logic](./cookbooks/custom-fallback.mdx).
- **Enrich or annotate.** Add headers, log structured events, or call out to
  other services.

## Next steps

- [Policy Chains](./policy-chains.mdx): execution order, options inheritance,
  and the built-in policies
- [Policy Templates](./policy-templates.mdx): roll a custom policy out to every
  new app in a team
- [Source Control](./source-control.mdx): how repository changes deploy
