Skip to main content

Output Controls

Response Rewriter

response-rewriter changes the text of a model answer before your application receives it, with ordered rules that replace, prepend, or append text. For example, it can replace email addresses and add a reminder to verify the answer. Every other field of the response stays the same.

It runs on Chat Completions and Responses with stream: false. It changes text and never blocks.

Configuration​

Save this as policy-config.yaml:

policy-config.yaml
pack:
  name: response-rewriter-example-1
  version: "1.0.0"
  enabled: true

policies:
  chain:
    - response-rewriter

policy:
  response-rewriter:
    rules:
      - name: append-disclaimer
        replacement: "\n\nAI output — verify independently."
        position: append
      - name: redact-email
        pattern: '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
        replacement: "[EMAIL]"
        position: replace

Before and after​

When the provider returns this in choices[0].message.content:

From the provider
Contact alice@example.com.

the client receives this in the same field:

To the client
Contact [EMAIL].

AI output — verify independently.

Fields​

FieldTypeDefaultDescription
ruleslist[]Rules in order. Each rule sees the text that earlier rules produced.
rules[].namestringRule name. Required.
rules[].replacementstringReplacement, prefix, or suffix text. Required.
rules[].patternstring""Regular expression for position: replace
rules[].positionreplace, prepend, or appendreplaceWhere the rule changes the text
rules[].conditionstringSubstring that the segment must contain for the rule to run. Case does not matter.
preserve_structurebooleantrueAccepted for compatibility. The JSON structure is always kept. The value appears in the details.

How it works​

  1. The gateway parses the JSON response.
  2. It runs the rules in order on each supported text segment. See Rewritten fields and Rule positions.
  3. If a rule changed a segment, it serializes and returns the changed JSON.
  4. The result is rewriter.applied when text changed and rewriter.no_match when it did not.
  5. The details record rules_evaluated, rules_applied, and preserve_structure, plus structure_preserved: true when the body changed.
  6. When the body changed and the verdict is allow, the top-level reason code of the event becomes rewriter.applied.

rules_applied counts each rule once for each segment it changed.

Rewritten fields​

ResponseFields
Chat CompletionsString choices[*].message.content values
ResponsesString output[*].content[*].text values

Top-level output strings, tool call arguments, audio, images, and other fields never change. A plain text body, invalid JSON, or an unsupported shape is returned unchanged.

Rule positions​

PositionBehavior
replaceReplaces each match of pattern with replacement. Capture references such as $1 work.
prependAdds replacement at the start, unless the segment already starts with it
appendAdds replacement at the end, unless the segment already ends with it

Phase and verdicts​

ItemValue
Input phaseNot checked
Output phaseRewrites the non-streaming response
Verdictsallow
Reason code, changedrewriter.applied
Reason code, unchangedrewriter.no_match

Examples​

Keep a capture group​

Inside policy-config.yaml
policy:
  response-rewriter:
    rules:
      - name: normalize-ticket-reference
        pattern: 'ticket ([0-9]+)'
        replacement: 'case $1'

Review ticket 4821 becomes Review case 4821.

Verify a rewrite through the gateway​

verdictan policy test does not run this control. Apply the file to a test agent with the config-first workflow and keep the gateway running. In the client terminal from the quickstart, with VERDICTAN_BASE_URL set to the agent URL ending in /v1:

curl -i "${VERDICTAN_BASE_URL}/chat/completions" \
  -H "Authorization: Bearer ${VERDICTAN_CLIENT_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o-mini",
    "stream": false,
    "messages": [
      {
        "role": "user",
        "content": "Reply with one short sentence."
      }
    ]
  }'

Use a model that the agent serves. Then check three things:

  1. The response body has the changed field inside a complete provider response.
  2. In verdictan events tail --since 10m --json, the response-rewriter result shows rewriter.applied and the expected rules_applied.
  3. If no rule applies unconditionally, text that matches no rule gives rewriter.no_match.

For rollout tests, use a deterministic test upstream. Model wording is not a stable fixture for a regular expression.

Limits and failure behavior​

  • The control never blocks. Put a blocking output control before it. A block stops the chain before the rewrite.
  • With stream: true, the control does not run.
  • A replace rule with invalid regular expression syntax is skipped silently.
  • The reason code comes from running the rules on extracted text. An unconditional prepend or append rule can report rewriter.applied for a body without supported fields. Check the returned body and structure_preserved.
  • Rules apply to each segment separately, so a prepend rule can prefix every choice or content part.
  • The duplicate checks for prepend and append compare exact strings.
  • Test every response family your application uses. One working JSON path does not prove another.
  • Parsing and serializing adds latency for large responses.
  • A when condition does not limit this control. Use routes.
ProblemCauseAction
rewriter.no_matchThe pattern or condition did not match, the pattern is invalid, or the body has no supported field.Read the upstream JSON and run the rules on the text.
Lint rejects a rulename or replacement is missing, or position is unknown.Add both, and use replace, prepend, or append.
Event shows allow but the text changedThe control rewrites and does not block.Read the nested response-rewriter result.
Streamed output unchangedThe control does not run on streams.Set stream: false.
A segment changed more than expectedRules run in order on each segment.Narrow the pattern or add a condition, and check rules_applied.

Next steps​