Skip to content

API reference

Private replies

saysafe.PrivacyGuard

Checks replies before they're spoken. Rules only by default: no models, no network.

classifier: optional extra detection layer, e.g. an LLM (see detect.CLASSIFIER_PROMPT). smoother: optional rewriter that makes redacted replies sound natural (see rewrite.smoother_prompt). screen: where private replies go, as the user would say it: "phone", "watch", "app". The host delivers send_to_phone there; the spoken lines name it.

check

check(text: str, *, sources: Iterable[str] = (), room: Room | str = Room.UNKNOWN, headphones: bool = False, discreet: bool = False, whisper: bool = False, context: str = '') -> PrivacyDecision

Where text may go.

sources: where the reply's data came from (otp, bank, health, email, calendar). room: who may hear the speaker; UNKNOWN is cautious. headphones: only the owner hears it, so everything is said. discreet / whisper: say nothing personal or above out loud. context: earlier text of the same reply that was already spoken. It's only used to find what makes text sensitive ("Your code is ready." then "It's 482913.").

says_as_is

says_as_is(text: str, *, sources: Iterable[str] = (), room: Room | str = Room.UNKNOWN, headphones: bool = False, discreet: bool = False, whisper: bool = False, context: str = '') -> bool

True if check() would speak text unchanged. Never calls the smoother, and skips detection when this room may hear anything.

speaks_everything

speaks_everything(*, room: Room | str = Room.UNKNOWN, headphones: bool = False, discreet: bool = False, whisper: bool = False) -> bool

True if any reply, however sensitive, may be said here (alone, or headphones).

detect

detect(text: str, *, sources: Iterable[str] = (), context: str = '') -> Detection

How sensitive text is. Context can only add to what text alone shows.

saysafe.PrivacyDecision dataclass

withheld property

withheld: bool

True when some of the reply was kept off the speaker.

saysafe.Room

Bases: str, Enum

Who may hear the speaker right now.

Most apps set it from device state: guest mode, passengers in the car, a meeting on the calendar -> OTHERS_PRESENT; a private-mode switch -> ALONE; nothing known -> UNKNOWN, which is cautious. With the voice extra, AudienceTracker.room() works it out from the voices it hears.

Approvals

saysafe.ApprovalGuard

Decides whether an action may run, and signs the approval.

secret: at least 16 random bytes (32 recommended), kept server-side. policy: a dict or YAML path with rules (and optionally the other sections of saysafe/data/policy.yaml); sections you leave out keep the defaults. thresholds: voice score bands (saysafe.load_thresholds(path) loads yours). The packaged ones are placeholders: calibrate on your own mic and users. require_voice_score: True (default) means a spoken yes or word only approves with a voice score in the accept band. Set False only if your app knows who's speaking another way (e.g. a signed-in phone line); then the reply alone approves. nonces: NonceStore or SQLite path for used tokens (default: in memory). steps: StepStore (default: in memory). audit: AuditLog for decisions (no audio, transcripts or embeddings). on_event(name, data): risk_assessed, readback, voice_scored, reply_matched, decision. screen: what the user approves on, as said out loud: "phone", "watch", "app".

assess

assess(action: Action) -> RiskAssessment

The tier this action needs, without starting anything.

start

start(action: Action, *, room: Room | str = Room.UNKNOWN, headphones: bool = False) -> Step

Begin approving action. room/headphones decide whether a money read-back may be said out loud; if not, it goes to the phone instead.

reply

reply(step: Step | str, transcript: str | None, *, voice_score: float | None = None, command_score: float | None = None) -> Step

The owner's reply to a read-back. transcript None means nobody answered in time.

how much the reply sounds like the owner (e.g. cosine to their

voiceprint), or None if you couldn't score it (too short, typed, no voice ID).

command_score: the same for the spoken command, if you have it; fused with the reply.

phone_approve

phone_approve(step: Step | str) -> Step

The owner tapped Approve. Works once, and only before the request expires.

cancel

cancel(step: Step | str) -> Step

Drop a step that's still waiting (the conversation moved on).

get

get(step_id: str) -> Step | None

The step as it stands now (a waiting step past its expiry reads as expired).

verify

verify(token: str, action: Action) -> dict

For the executor, right before it runs action: returns the token's payload, or raises Refused (bad_signature, action_changed, expired, replayed). Single use.

saysafe.Step dataclass

Where an approval stands. Every ApprovalGuard call returns the latest Step.

saysafe.StepClosed

Bases: Exception

The step can't take this call: it's resolved, expired, waiting for something else, or unknown. status is its current status, or None if unknown.

saysafe.Action

Bases: BaseModel

Something the agent wants to do. source: user_voice (the owner asked for it), agent_initiated (the assistant suggested it) or from_content (an email, a web page or a message asked for it); the last two need one more tier of proof.

saysafe.Refused

Bases: Exception

The executor must not run the action. reason is one of the REASONS.

saysafe.approvals.guard.StepStore

Bases: Protocol

Where steps live between calls. Memory by default; implement this for a store shared by several processes (e.g. a phone webhook served by another worker).

swap

swap(old: Step, new: Step) -> bool

Atomically replace old with new if the stored step still has old.version.

saysafe.approvals.tokens.NonceStore

Bases: Protocol

burn

burn(nonce: str, at: float) -> bool

Record a nonce as used. True the first time, False if it was already used. Must be atomic: two concurrent burns of one nonce can't both return True.

saysafe.AuditLog

Voice scores

saysafe.Thresholds dataclass

saysafe.calibrate

calibrate(owner: Sequence[float], others: Sequence[float], target: float = 0.01) -> Thresholds

Thresholds from your own scores: the owner's held-out clips vs other voices.

t_accept is the lowest threshold that accepts at most target of the other voices; t_reject the highest that rejects at most target of the owner's clips. When the two sets overlap, the band between them is "uncertain" and steps up to the phone. Use a few hundred scores of each, recorded on the mic you ship, and keep them separate from the clips you enrolled with.

saysafe.load_thresholds

load_thresholds(path: str | Path | None = None) -> Thresholds

Thresholds from a YAML file with t_accept and t_reject (e.g. your own calibration), or the packaged placeholders. Calibrate on your own mic and users before relying on them.

Voice ID

saysafe.voice.VoiceID

model_dir: where the ECAPA model is kept (downloaded there on first use). min_speech_s: speech needed to score a clip; replies like "yes" are short, so the default is policy.yaml's min_reply_speech_s (0.35 s). Shorter clips score None.

warm

warm() -> None

Load the models now, so the first real score isn't slow.

embed

embed(audio: ndarray, *, min_speech_s: float | None = None) -> Embedding

The speech-only part of audio as a unit vector. Raises TooShort.

score

score(audio: ndarray, profile: Profile, *, min_speech_s: float | None = None) -> float | None

Cosine between audio and the profile, or None with too little speech.

verify

verify(audio: ndarray, profile: Profile, thresholds: Thresholds | None = None, *, min_speech_s: float | None = None) -> VerifyResult

Score plus band (accept / uncertain / reject) and how much speech there was.

enroll

enroll(name: str, clips: Sequence[ndarray], *, mic_name: str = 'unknown', min_clips: int = 3) -> Profile

A profile from clips of one person speaking (3 s each is plenty; 5-8 clips).

Clips with under 0.8 s of speech are skipped, and so are clips that don't sound like the rest. Raises EnrollmentError if fewer than min_clips are left. Save the profile with ProfileStore, which encrypts it.

tracker

tracker(profile: Profile, **kwargs) -> AudienceTracker

An AudienceTracker that labels each speech segment owner / other / unclear.

saysafe.voice.ProfileStore

__init__

__init__(directory: Path | str, key: bytes)

Profiles are Fernet-encrypted with key (Fernet.generate_key(), or keyring_key()).

Pipecat

saysafe.integrations.pipecat.PrivacyFilter

Bases: FrameProcessor

Checks LLM replies and TTSSpeakFrames before TTS.

delivers withheld replies privately (to guard.screen). Required, because

the spoken line says "I sent it to your phone"; if it raises, failed_line() is said.

guard: a PrivacyGuard, e.g. with an LLM classifier; rules only by default. room / headphones: values, or zero-argument callables read for every sentence (e.g. room=tracker.room). Set the attributes any time. tool_sources: function name -> source tags, applied to the reply after its result.

Event "on_decision"(processor, decision): every reply that was checked as a whole.