CrewAI
Protect CrewAI crews with OGuardAI so PII is tokenized before the model and restored outside the crew
The OGuardAI Python SDK ships an optional CrewAI integration built around GuardedLLMHooks, a paired
before/after hook in CrewAI's native LLMCallHookContext shape. The before hook tokenizes every
message content in place, so every LLM call the crew makes sees only the tokenized safe_text. It
threads one sealed session across every message and every iteration, so token ids stay stable for the
whole crew. It fails closed: if a transform returns no session_state, before raises and the LLM
call is blocked. The after hook restores the tokens in the model response.
The single sealed session is held on the GuardedLLMHooks instance, never inside CrewAI. CrewAI, its
memory, and any telemetry only ever handle tokens and safe metadata during LLM calls.
Install
The integration is an optional extra, so CrewAI is only pulled in when you use it.
pip install "oguardai-sdk[crewai]"CrewAI is imported lazily (only when you register the hooks), so importing guardai_sdk never
requires CrewAI.
Two ways to run it
Both keep raw PII out of the model: before tokenizes every LLM call in place either way. They differ
in where restoration happens relative to the crew.
- Boundary-safe (recommended). Wire only
before. Every LLM call the crew makes is tokenized, the crew's own outputs stay tokenized, and you restore the single final crew output once withclient.rehydrateafterkickoffreturns, outside CrewAI. No CrewAI memory, downstream agent, or tracer ever observes a restored value. - In-framework (
register).register()wiresbeforeandafter. Theafterhook restores the tokens incontext.responseand returns the restored string, which CrewAI then uses as the LLM response. This is convenient, but the restored (real) values re-enter the crew: any subsequent agent step, CrewAI memory, or tracer attached at or below the LLM-call hook can observe them. Use it only when CrewAI's own telemetry and memory are not exported to a PII-sensitive sink.
Boundary-safe path
from guardai_sdk import OGuardAIClient
from guardai_sdk.integrations.crewai import GuardedLLMHooks
from crewai.hooks import register_before_llm_call_hook
client = OGuardAIClient(base_url="http://localhost:3000")
hooks = GuardedLLMHooks(client, policy="german-support", language="de")
# Wire ONLY the inbound tokenizer. Every LLM call the crew makes is tokenized in
# place and one sealed session is threaded across all of them. The crew's own
# outputs stay tokenized; nothing is restored inside CrewAI.
register_before_llm_call_hook(hooks.before)
result = crew.kickoff(inputs={"lead": raw_lead_record})
# Restore once, outside the crew, on the sealed session the before-hook threaded.
final = client.rehydrate(
str(result),
session_state=hooks.session_state,
output_channel="customer_email",
restore_mode="full",
).restored_texthooks.session_state is the sealed session the before hook threaded across the crew's LLM calls.
Read it through that public property, or drive client.transform / client.rehydrate around the
crew yourself if you prefer not to. If the crew made no PII-bearing call, session_state stays
None; guard for that before calling rehydrate, which requires a non-empty session.
In-framework path
from guardai_sdk import OGuardAIClient
from guardai_sdk.integrations.crewai import GuardedLLMHooks
client = OGuardAIClient(base_url="http://localhost:3000")
# Wires `before` + `after` onto CrewAI's global hooks. `rehydrate_in_hook=True`
# opts into in-place restoration, so each LLM response is restored before it
# re-enters the crew and the framework (and any tracer) sees the restored output.
GuardedLLMHooks(
client, policy="german-support", language="de", rehydrate_in_hook=True
).register()
result = crew.kickoff(inputs={"lead": raw_lead_record})The constructor's output_channel (default "user_output") and restore_mode (default "full")
control the after hook's restore in this path. In the boundary-safe path they are ignored, because
you pass the channel and mode straight to client.rehydrate.
What the model and tracer see
During every LLM call, the model and CrewAI's telemetry only ever observe tokenized text like
Kontakt {{person:p_001:ad4f97591c16}} bei {{company:c_001:dfe4fe5cf129}}, {{email:e_001:1bcaef1a4aff}} plus the safe metadata the policy
permits (gender, formality, language). In the boundary-safe setup, restoration happens with
client.rehydrate after kickoff returns, entirely outside CrewAI, so no CrewAI memory, no
subsequent agent, and no tracer ever sees a raw or a restored value. In the in-framework setup,
after replaces context.response with the restored text, so from that point on the restored value
is visible to anything attached at or below the LLM-call hook.
How it works
beforereadscontext.messages, and for each string-bearingcontentcalls/v1/transform, replacing PII with semantic tokens and writing thesafe_textback into the message in place. It chains the sealedsession_statefrom one message to the next, and persists it on the hooks instance so it carries across every LLM call the crew makes.- The crew runs on the tokenized messages. The model drafts on tokens and safe metadata only.
- Restoration calls
/v1/rehydratewith the sealed session. In the boundary-safe path you call it once on the final crew output, outside CrewAI; in the in-framework pathaftercalls it on each response and returns the restored string to CrewAI.
If a transform returns no session_state, before raises ValidationError and the LLM call is
blocked (fail closed). Raw PII is never sent to the model on a failure.
Runnable example: guarded CRM outreach crew
A CRM outreach copilot for a fictional B2B sales team: a guarded two-agent CrewAI crew drafts a
personalized German follow-up email to an inbound lead whose CRM record is dense with regulated PII
(contact name, business email, phone, company, postal address, opportunity id, deal value). PII
becomes semantic tokens before every LLM call, and the real values come back per audience after the
crew finishes, restored outside the crew. The policy is
german-support: German formal restore, contact details
tightened on the internal channel, fully de-identified on the log_safe channel.
The full source is in the repository at examples/crewai-python/guard.py. It has two sections: a
boundary proof that runs against the server with no LLM provider, and a live two-agent crew that runs
only when CrewAI and an OpenAI key are present.
Prerequisites
Needs-NER for the full scenario. Person and company detection, plus the German honorific restore (Herr/Frau), need the NER detector, so start the OGuardAI server with the detector sidecar:
# From the repo root. The bundled stack requires these four secrets, and its client key ships
# least-privilege (channel user_output only), so widen it to the channels this example uses.
export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)
export GUARDAI_API_KEY_1=$(openssl rand -hex 32)
export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)
export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)
export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,customer_email,log_safe
docker compose -f deploy/docker/docker-compose.yml up -dBuiltin regex alone still masks structured PII (email, phone, address, ids), it just cannot detect
names or companies. The example's german-support policy declares person detection as required, so a
runtime without the NER sidecar rejects the transform (fail closed, HTTP 503) rather than let an
un-tokenized name reach the model; run the full stack with the detector.
Install the Python dependencies:
pip install -r requirements.txtCrewAI and OPENAI_API_KEY are needed only for section 2 (the live crew). Without them, section 1
still runs and proves the boundary against the server.
Run
export GUARDAI_BASE_URL="http://localhost:3000" # or GUARDAI_URL / GUARDAI_BASE
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1" # the bundled stack runs api_key auth
export OPENAI_API_KEY="sk-..." # optional: enables the live crew
python guard.pyDependencies (examples/crewai-python/requirements.txt)
oguardai-sdk>=0.1.0
# The live crew (section 2) needs CrewAI. The boundary proof (section 1) runs with
# only the SDK against the OGuardAI server, so CrewAI is optional for that section.
crewai>=1.15,<2The fixture and the sentinels
The lead record, the policy, and the sentinels the proof checks against. From
examples/crewai-python/guard.py:
from __future__ import annotations
import os
import re
import sys
from dataclasses import dataclass, field
from guardai_sdk import OGuardAIClient
from guardai_sdk.errors import OGuardAIError
from guardai_sdk.integrations.crewai import GuardedLLMHooks
BASE = (
os.environ.get("GUARDAI_BASE_URL")
or os.environ.get("GUARDAI_URL")
or os.environ.get("GUARDAI_BASE")
or "http://localhost:3000"
).rstrip("/")
API_KEY = os.environ.get("GUARDAI_API_KEY") or None
# One vertical policy governs the run: German formal restore (Herr/Frau), contact
# details tightened on the internal channel, fully de-identified on the log channel.
POLICY = "german-support"
LANG = "de"
TOKEN_RE = re.compile(r"\{\{[^}]+\}\}")
# A fictional inbound lead record. Every name, email, phone, address, id, and value
# here is invented. Sending this raw to a model is the incident OGuardAI prevents.
RAW_LEAD_RECORD = (
"Lead: Dr. Erika Mustermann\n"
"Firma: Musterfirma GmbH\n"
"E-Mail: erika.mustermann@example.de\n"
"Telefon: 089 12345678\n"
"Anschrift: Lindwurmstrasse 42, 80337 Muenchen\n"
"Opportunity: OPP-2026-4471\n"
"Volumen: 48.000 EUR\n"
"Notiz: Interessiert an einer Demo unserer Portalloesung, bittet um Rueckruf diese Woche."
)
# Split the sentinels by how the runtime protects them. STRUCTURED values are masked by
# builtin regex on every lane, so any crossing is a hard leak. The person name needs NER, so a
# raw name is a failure only when NER is configured, otherwise a printed limitation. The
# opportunity id, address, and volume are not builtin-detectable, so they are not claimed here.
STRUCTURED_SECRETS = {
"email": "erika.mustermann@example.de",
"phone": "089 12345678",
}
NAME_SECRET = "Erika Mustermann"The boundary proof (no LLM provider needed)
Section 1 runs the real hooks.before on a lead record carried on a message, asserts the model would
see tokens and no structured PII, then rehydrates the tokenized draft on two channels outside the
crew and asserts the policy split. FakeLLMCallContext stands in for CrewAI's LLMCallHookContext,
so this section needs only the server. From examples/crewai-python/guard.py:
@dataclass
class FakeLLMCallContext:
"""Stands in for CrewAI's ``LLMCallHookContext``, exposing only the two attributes
the adapter reads: ``messages`` (list of role/content dicts, mutated in place) and
``response`` (the model output string). Lets the boundary proof exercise the REAL
``hooks.before`` / restore path without a live model or CrewAI installed."""
messages: list[dict] = field(default_factory=list)
response: str | None = None
def tokens_in(text: str) -> list[str]:
return TOKEN_RE.findall(text)
def fetch_ner_active(client: OGuardAIClient) -> bool:
"""The one signal that decides whether a raw name crossing is a failure or a noted
limitation. A NER-configured runtime tokenizes names, so a raw name is a real leak; a
builtin-only runtime cannot, so a raw name is an expected, printed degradation. If the
signal cannot be read, treat NER as available so a name leak still fails."""
try:
return client.capabilities().ner_active is not False
except (OGuardAIError, ValueError):
return True
def check_boundary(where: str, text: str, ner_active: bool) -> list[str]:
"""Guard one string that crosses to the model. Any structured secret present is a leak
that fails on every lane. A raw name fails only when the runtime runs NER; when it does
not it is a printed note, never silent. Returns the name note recorded, if any."""
leaked = [label for label, raw in STRUCTURED_SECRETS.items() if raw in text]
if leaked:
raise SystemExit(f"FAIL: structured PII crossed to the model in {where}: {', '.join(leaked)}")
if NAME_SECRET in text:
if ner_active:
raise SystemExit(
f"FAIL: the name {NAME_SECRET} crossed to the model in {where}; "
"this runtime runs NER, so names must be tokenized"
)
print(f" NOTE: the name {NAME_SECRET} crossed in {where}; this runtime runs without "
"NER, so names are not tokenized. Start the full stack for German name masking.")
return [NAME_SECRET]
return []
def boundary_proof(client: OGuardAIClient, ner_active: bool) -> list[str]:
"""Run the real before-hook on a lead record and prove the model would see tokens,
then rehydrate OUTSIDE the framework on two channels and prove the policy split."""
print("=" * 78)
print("1. BOUNDARY PROOF (real hooks.before, no LLM provider needed)")
print("=" * 78)
hooks = GuardedLLMHooks(client, policy=POLICY, language=LANG)
ctx = FakeLLMCallContext(
messages=[
{"role": "system", "content": "Entwirf eine kurze, formale Follow-up-Mail an den Lead."},
{"role": "user", "content": RAW_LEAD_RECORD},
]
)
# This is exactly what CrewAI calls before each LLM request; it rewrites every
# message content to safe_text in place and threads the sealed session.
hooks.before(ctx)
safe = ctx.messages[-1]["content"]
print(f" model would see (user message): {' '.join(safe.split())[:200]}...")
if not tokens_in(safe):
raise SystemExit("FAIL: no semantic tokens in the tokenized message")
if not hooks.session_state:
raise SystemExit("FAIL: the before-hook threaded no sealed session")
# Structured secrets must be gone on every lane; a raw name fails only under NER.
name_notes = check_boundary("the model input", safe, ner_active)
print(f" checked: {len(tokens_in(safe))} token(s), no structured PII in the model input")
# The crew's final output would carry the tokens the agents echoed. Simulate that
# output from the actual tokens present, then rehydrate it OUTSIDE the crew.
drafted_on_tokens = (
f"Sehr geehrte {token_of(safe, 'person')}, vielen Dank fuer Ihr Interesse. "
f"Wir melden uns unter {token_of(safe, 'email')} bzw. {token_of(safe, 'phone')} "
f"zum Vorgang {token_of(safe, 'order')}."
)
customer = client.rehydrate(
drafted_on_tokens,
session_state=hooks.session_state,
output_channel="customer_email",
restore_mode="full",
).restored_text
log_line = client.rehydrate(
drafted_on_tokens,
session_state=hooks.session_state,
output_channel="log_safe",
).restored_text
print(f" -> prospect email : {' '.join(customer.split())[:200]}")
print(f" -> CRM audit log : {' '.join(log_line.split())[:200]}")
# The customer channel restores the real email; the log channel must not.
if STRUCTURED_SECRETS["email"] not in customer:
raise SystemExit("FAIL: the customer channel did not restore the email")
if STRUCTURED_SECRETS["email"] in log_line:
raise SystemExit("FAIL: the audit log channel leaked the real email")
print(" checked: customer channel restored the email in full; audit log stayed de-identified\n")
return name_notes
def token_of(safe_text: str, type_key: str) -> str:
"""Exact token string for one entity type, or a plain placeholder if the detector
did not produce it (e.g. person/company without NER), so the proof still runs."""
for token in tokens_in(safe_text):
if token[2:].split(":", 1)[0] == type_key:
return token
return f"[{type_key}]"The live crew (boundary-safe wiring)
Section 2 builds a real Researcher plus Writer crew and wires only the before hook, then rehydrates
the final output once, outside the crew. It records every string that crosses to the model and scans
it against the same sentinels, so a live leak fails here too. It is skipped cleanly when CrewAI or
OPENAI_API_KEY is missing. From examples/crewai-python/guard.py:
def record_message_crossings(context: object, sink: list[str]) -> None:
"""Append every string the before-hook left on the outgoing messages so the exact text the
live model receives enters the leak record, even when the sealed session, held in a
context-local, does not thread back to this frame."""
messages = getattr(context, "messages", None)
if not isinstance(messages, list):
return
for message in messages:
if not isinstance(message, dict):
continue
content = message.get("content")
if content is None:
continue
sink.append(content if isinstance(content, str) else str(content))
def run_live_crew(client: OGuardAIClient, ner_active: bool) -> list[str]:
"""Boundary-safe wiring on a real crew. Only runs with CrewAI installed AND an OpenAI key set;
otherwise it is skipped so the example still proves section 1. Every string that crosses to the
model in the live run, the tokenized message the before-hook produced and the crew's own
output, is scanned against the same sentinels as the offline proof and feeds the exit and the
closing, so a live leak fails here instead of being masked by the earlier proof passing. The
crossings are collected in a plain list, not the sealed session, because CrewAI may fire the
hook on a worker thread whose context-local session never threads back to this frame."""
print("=" * 78)
print("2. LIVE CREW (boundary-safe: before-hook only, rehydrate outside)")
print("=" * 78)
if not os.environ.get("OPENAI_API_KEY"):
print(" skipped: set OPENAI_API_KEY to run the live crew.\n")
return []
try:
from crewai import Agent, Crew, Process, Task
from crewai.hooks import before_llm_call
except ImportError:
print(" skipped: CrewAI is not installed (pip install \"oguardai-sdk[crewai]\").\n")
return []
hooks = GuardedLLMHooks(client, policy=POLICY, language=LANG)
model_inputs: list[str] = []
def guarded_before(context: object) -> None:
hooks.before(context)
record_message_crossings(context, model_inputs)
before_llm_call(guarded_before) # tokenize every LLM call; do NOT wire `after`
researcher = Agent(
role="Lead Researcher",
goal="Fasse den Bedarf des Leads aus dem Datensatz in einem Satz zusammen.",
backstory="Du arbeitest im Vertriebsinnendienst und liest CRM-Datensaetze praezise.",
)
writer = Agent(
role="Outreach Writer",
goal="Schreibe eine kurze, formale deutsche Follow-up-Mail an den Lead.",
backstory="Du formulierst hoefliche, korrekte Vertriebs-Mails in der Sie-Form.",
)
research_task = Task(
description=f"Analysiere diesen Lead-Datensatz und nenne den Bedarf:\n{RAW_LEAD_RECORD}",
expected_output="Ein Satz zum Bedarf des Leads.",
agent=researcher,
)
write_task = Task(
description=(
"Entwirf die Follow-up-Mail. Verwende die semantischen Token exakt wie gegeben "
"und erfinde keine Namen oder Nummern."
),
expected_output="Eine kurze deutsche Follow-up-Mail.",
agent=writer,
)
crew = Crew(agents=[researcher, writer], tasks=[research_task, write_task], process=Process.sequential)
result = crew.kickoff()
name_notes: list[str] = []
for crossing in dict.fromkeys(model_inputs):
name_notes += check_boundary("the live crew model input", crossing, ner_active)
name_notes += check_boundary("the live crew output", str(result), ner_active)
name_notes = list(dict.fromkeys(name_notes))
state = hooks.session_state
if not state:
print(" crew finished; the recorded crossings carried no structured PII and could not be "
"rehydrated here (boundary-safe threading).\n")
return name_notes
final = client.rehydrate(
str(result),
session_state=state,
output_channel="customer_email",
restore_mode="full",
).restored_text
print(f" -> restored prospect email: {' '.join(final.split())[:300]}\n")
return name_notesWiring it together
main checks server health, confirms the NER detector is active (which this example's german-support
policy requires, so a runtime without it fails the transform closed), then runs the boundary proof and
the live crew and prints the closing summary. From examples/crewai-python/guard.py:
def main() -> None:
with OGuardAIClient(base_url=BASE, api_key=API_KEY) as client:
try:
health = client.health()
except OGuardAIError as exc:
raise SystemExit(f"Cannot reach OGuardAI at {BASE}: {exc}")
ner_active = fetch_ner_active(client)
if not ner_active:
print(
"WARNING: NER detector not active; person and company tokens (and the German "
"honorific restore) will be missing. Start the full stack via docker compose.\n",
file=sys.stderr,
)
print(f"Server: {BASE} status={health.status} policy={POLICY}\n")
name_notes = boundary_proof(client, ner_active)
name_notes = list(dict.fromkeys(name_notes + run_live_crew(client, ner_active)))
tracked = ", ".join(STRUCTURED_SECRETS)
if name_notes:
print(f"Done. The detectable structured secrets ({tracked}) stayed inside the runtime; the name "
f"{NAME_SECRET} reached the model because this runtime runs without NER. Start the full "
"stack for German name masking.")
else:
print(f"Done. The detectable structured secrets ({tracked}) stayed inside the trusted zone and the "
"crew and every downstream sink saw them only as tokens.")
if __name__ == "__main__":
main()Expected output
With the full stack (server plus NER) and OPENAI_API_KEY set, python guard.py prints a header, the
two numbered sections, and a closing line. Section 1 prints the tokenized message the model would see,
the token count with no structured PII, and the two rehydrated channels:
1. BOUNDARY PROOF (real hooks.before, no LLM provider needed)
model would see (user message): ...
checked: N token(s), no structured PII in the model input
-> prospect email : ...
-> CRM audit log : ...
checked: customer channel restored the email in full; audit log stayed de-identifiedThe customer channel restores the real email in full; the CRM audit log stays de-identified. Section 2
either prints skipped: set OPENAI_API_KEY to run the live crew. (or a CrewAI-not-installed skip), or
runs the live crew and prints the restored prospect email. The run ends with:
Done. The detectable structured secrets (email, phone) stayed inside the trusted zone and the crew and every downstream sink saw them only as tokens.Because this example's german-support policy requires NER for the person name, a runtime without the
detector rejects the transform (fail closed, HTTP 503) rather than let an un-tokenized name reach the
model; start the full stack for German name masking. A structured secret (email or phone) crossing to
the model is always a hard failure and exits nonzero.