LLM Providers

Where blocked terms should live for AWS Bedrock Guardrails with Sanity Context

AWS Bedrock

Managed content filtering and denied-topic guardrails for Amazon Bedrock models, configured per guardrail version through the ApplyGuardrail API.

Visit AWS Bedrock

You added a Bedrock Guardrail with a denied topic and a word policy, shipped it, and two weeks later a legal review flagged a term the model still said. The blocked-terms list lived in a guardrail version in the AWS console, nobody on the content team could edit it, and getting a new word added meant a Jira ticket, a redeploy of your CreateGuardrailVersion pipeline, and a wait. Your filtering rules drifted from the brand and compliance rules the humans actually maintain.

Sanity Context is Sanity's agent-facing product. Its primary surface today is Context MCP, a hosted, read-only MCP endpoint that exposes schema reads, GROQ queries, reference traversal, and optional semantic search across a Sanity dataset. Knowledge Bases is the second surface, for unstructured sources like PDFs and support docs. The point for a Bedrock user: the blocked-terms and denied-topic definitions can live as governed, versioned documents that content and compliance teams edit directly, and your guardrail pipeline reads them at build time.

This article covers what Bedrock Guardrails actually enforce, where the ApplyGuardrail boundary sits, and why the source of truth for blocked terms belongs in an editable content model rather than a console field.

What do Bedrock Guardrails actually block?

A Bedrock Guardrail is a set of independent policies you attach to a model invocation, not a single filter. There are four you configure: content filters (hate, insults, sexual, violence, misconduct, prompt attacks, each with a LOW/MEDIUM/HIGH strength), denied topics (natural-language topic definitions the model refuses to discuss), word policies (a literal blocked-words list plus managed profanity), and sensitive information filters (PII regexes and managed PII types). Each policy runs on both the input prompt and the output completion, and you choose the action: BLOCK the whole turn or MASK the offending span.

The word policy is the one teams reach for when they want a hard blocklist, competitor names, unreleased product codenames, a slur list that legal maintains. It is an exact and managed-list match, not semantic. If your blocked term is 'Project Halibut' and the model writes 'the Halibut project', the word policy on its own will not catch the reordering. That gap is why denied topics exist: a denied topic is a description plus a few example phrases, and Bedrock uses the model to decide whether the turn is on that topic. Denied topics catch paraphrase; word policies catch exact strings. Real guardrails use both.

The critical structural fact: none of these policies live in your model. They live in a guardrail identified by guardrailIdentifier and a specific guardrailVersion. Version 1 is immutable once published. Editing a blocked-words list means creating a new draft, testing it, and publishing a new version, then pointing your application at that version. The list is data, but AWS stores it inside a versioned config object you mutate through the API, not somewhere a content editor can reach.

How do you apply a guardrail to a Bedrock call?

There are two ways to enforce a guardrail. The first is inline: pass guardrailConfig to InvokeModel or Converse, and Bedrock runs the policies around that single model call. The second is the ApplyGuardrail API, which runs the guardrail on arbitrary text with no model invocation at all. ApplyGuardrail is the one that matters for architecture, because it lets you check text you generated somewhere else, a RAG chunk before it goes into the prompt, a tool result before the agent sees it, a final answer assembled from multiple calls.

The response tells you what happened. You get an 'action' of GUARDRAIL_INTERVENED or NONE, and an 'assessments' array breaking down which policy fired and on which span. When the word policy hits, the assessment includes the matched 'customWords' entry, so you know exactly which blocked term triggered it. That structured output is what you log, and it is also what tells you your blocklist is doing its job or catching false positives.

Apply a Bedrock Guardrail to arbitrary text with the Python SDK

ApplyGuardrail runs the word policy on text you already have, no model call needed.

import boto3

bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")

response = bedrock.apply_guardrail(
    guardrailIdentifier="abcd1234wxyz",
    guardrailVersion="3",
    source="OUTPUT",
    content=[
        {
            "text": {
                "text": "Our roadmap includes Project Halibut shipping in Q3."
            }
        }
    ],
)

print(response["action"])  # GUARDRAIL_INTERVENED or NONE
for a in response.get("assessments", []):
    word_policy = a.get("wordPolicy", {})
    for match in word_policy.get("customWords", []):
        print("blocked term hit:", match["match"])

Why does editing a Bedrock blocked-words list hurt in production?

The pain is not that the word policy is weak. It is that the source of truth is in the wrong hands. A Bedrock guardrail version is an immutable config object mutated through CreateGuardrail, UpdateGuardrail, and CreateGuardrailVersion. To add a term, someone with IAM permissions on the Bedrock control plane edits the guardrail, publishes a new version, and your application config gets updated to reference it. That someone is almost never the person who knows the term needs blocking.

Compliance flags a new competitor name. Brand adds an embargoed codename. Legal updates a slur list after a policy review. These are content decisions made by content people, and every one of them currently routes through an engineer and a deploy. The blocklist also has no review workflow of its own inside Bedrock, no draft-versus-published distinction a non-engineer can see, no audit trail of who added 'Project Halibut' and why, no preview of what the guardrail will block before it goes live. You get version numbers, but a version number is not a changelog with an author and a reason.

The second failure is drift. The same list of forbidden terms often already exists elsewhere: in your CMS as a set of unpublished-product documents, in a spreadsheet of restricted brand terms, in the frontmatter of your style guide. When the guardrail's copy of that list is maintained separately, the two diverge. A product gets announced, the CMS document flips to published, but the Bedrock word policy still blocks the name. Now your support agent refuses to discuss a shipped product because a config object three teams removed from the launch never got updated.

Where should the blocked-terms list actually live?

The blocked-terms list should live where content and compliance teams already work, as governed documents, and your guardrail pipeline should read that source at build time rather than storing a second copy. This is exactly the editorial-state problem Sanity Context is built for. You model a blockedTerm document type with fields for the term, its variants, the reason, an owner, and a status, and the people who own those decisions edit them in Sanity Studio with a real review workflow behind them.

The read path is Context MCP, the hosted, read-only MCP endpoint. Your guardrail build job queries the current set of active blocked terms with a GROQ query, then feeds them into CreateGuardrailVersion so the published Bedrock guardrail reflects the content team's current decisions. Because the endpoint is read-only, the pipeline can pull the list but cannot mutate your content, which is the correct blast radius for a build step. Content Releases gives you the draft-versus-published distinction Bedrock lacks: an embargoed codename lives in an unpublished release, and the exact moment the product launches and the document publishes, your next guardrail build stops blocking the name. The two lists stay in sync because there is only one list.

This is the general shape of Sanity as the Content Operating System for the AI era: the governed, human-edited, versioned decisions live in one place, and the machine surfaces, here a Bedrock guardrail, read from it rather than each keeping a private copy that rots.

Read active blocked terms from Sanity Context via next-sanity

GROQ selects only active terms; the pipeline maps them into Bedrock's wordsConfig.

import { createClient } from "next-sanity"

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: "production",
  apiVersion: "2024-01-01",
  useCdn: false,
})

// Pull the current active blocklist at guardrail build time.
const terms = await sanity.fetch<string[]>(
  `*[_type == "blockedTerm" && status == "active"].term`
)

// terms -> wordsConfig for CreateGuardrailVersion
const wordsConfig = terms.map((t) => ({ text: t }))

How do you sync the Sanity blocklist into a Bedrock guardrail?

The sync is a build step, not a runtime dependency. You do not want to call Sanity on every model invocation; you want the guardrail version to be the compiled artifact of your current blocklist. So the flow is: content team edits blockedTerm documents, a publish event or a scheduled job triggers your sync, the job reads the active terms through Context MCP or the GROQ API, and it calls Bedrock's UpdateGuardrail followed by CreateGuardrailVersion. Your application config then points at the new version.

The reason to compile rather than call live is latency and immutability. A published guardrail version is immutable and cached at the edge of Bedrock's enforcement path, which is what makes ApplyGuardrail fast. If you tried to read the blocklist live per request, you would add a network hop to every guarded call and lose the immutability guarantee that lets you say exactly which version blocked a given output in an audit. Compile the list into a version, record the Sanity document revision IDs that produced it, and you have a defensible chain: this guardrail version was built from these exact content decisions at this timestamp.

Publish a new Bedrock guardrail version from the synced word policy

UpdateGuardrail then CreateGuardrailVersion turns the synced list into an immutable version.

import boto3

bedrock = boto3.client("bedrock", region_name="us-east-1")

# words_config comes from the Sanity GROQ read: [{"text": "Project Halibut"}, ...]
def sync_guardrail(guardrail_id: str, words_config: list[dict]) -> str:
    bedrock.update_guardrail(
        guardrailIdentifier=guardrail_id,
        name="support-agent-guardrail",
        blockedInputMessaging="I can't help with that.",
        blockedOutputsMessaging="I can't help with that.",
        wordPolicyConfig={"wordsConfig": words_config},
    )
    version = bedrock.create_guardrail_version(
        guardrailIdentifier=guardrail_id,
        description="Built from Sanity blockedTerm docs",
    )
    return version["version"]  # point your app config at this

When should terms stay in Bedrock and not move to content?

Not every guardrail rule belongs in a content model, and pretending otherwise would be dishonest about where the boundary sits. Bedrock's managed content filters, hate, prompt-attack detection, managed PII types, are model-driven classifiers AWS trains and updates. Those are not word lists; there is nothing for a content editor to author. Leave them configured in the guardrail and out of Sanity entirely. The same goes for denied topics that are genuinely operational rather than editorial, a topic your agent should refuse for legal reasons that never changes with a product launch.

The blocked-terms list is the piece that moves, because it is a list of proper nouns and phrases that map directly to content decisions the CMS already tracks: unreleased products, embargoed names, restricted brand terms, competitor mentions. Those change on the same cadence as your content and are owned by the same people. A useful test: if the reason a term is blocked is 'the product has not launched yet', the launch state already lives in your content model, and the guardrail should read from it. If the reason is 'the model should never produce hate speech', that is a managed filter and it stays in Bedrock.

High-volume machine-generated blocklists, an automatically scraped abuse-term feed with tens of thousands of entries and no human curation, also do not need editorial governance and can stay in a dedicated store the pipeline reads directly. The dividing line is whether a human makes and reviews the decision. When they do, the decision wants a content model with an author, a reason, and a review workflow. When a classifier makes it, leave it in Bedrock.

⚠️

Don't call Sanity on the hot path

Reading the blocklist live on every InvokeModel call adds a network hop to your latency budget and breaks the immutability guarantee that lets you name which guardrail version blocked a given output in an audit. Compile the Sanity blockedTerm documents into an immutable Bedrock guardrail version at build time, and record the document revision IDs that produced it.