Control what leaves the device
Every request bound for a networked provider passes through one hook before it leaves the process: the pre-transmit filter. Return the request — possibly rewritten — to allow it. Throw to refuse it; the provider is never invoked.
Write a filter
PreTransmitFilter is one method. It receives the fully built outgoing
request (system prompt, message history, tools, sampling) and returns what
ships:
struct RedactingFilter: PreTransmitFilter {
func filter(_ request: CompletionRequest) async throws -> CompletionRequest {
let redacted = request.messages.map { message in
Message(
id: message.id,
role: message.role,
content: message.content.map { item -> MessageContent in
guard case .text(let text) = item else { return item }
return .text(text.replacingOccurrences(
of: #"\d{3}-\d{2}-\d{4}"#,
with: "[REDACTED]",
options: .regularExpression
))
}
)
}
return CompletionRequest(
systemPrompt: request.systemPrompt,
messages: redacted,
tools: request.tools,
sampling: request.sampling,
structuredOutput: request.structuredOutput,
maxToolCallsPerTurn: request.maxToolCallsPerTurn,
toolChoice: request.toolChoice
)
}
}
Attach it when you build the agent:
let agent = try runtime.makeAgent(
provider: .anthropic(apiKey: key),
role: AgentRole(staticPersona: "You are a careful assistant."),
preTransmitFilter: RedactingFilter()
)
The filter runs before each provider request — every tool round trip within a turn, and structured-output turns too. What you return is what ships, with one boundary: the system prompt, messages, and sampling come from your filtered request; the tools, structured-output spec, and tool choice always come from the original. The filter redacts content — it does not renegotiate the turn's contract.
Refuse egress
Throwing from the filter refuses the request entirely:
struct OfflineOnlyFilter: PreTransmitFilter {
struct PolicyViolation: Error, CustomStringConvertible {
let description = "outbound request blocked by policy"
}
func filter(_ request: CompletionRequest) async throws -> CompletionRequest {
throw PolicyViolation()
}
}
The turn fails with a typed error and the provider never sees the request:
do {
try await agent.send(text)
} catch AgentSessionError.egressRefused(let detail) {
// nothing left the device — `detail` is your filter's own message
}
detail carries your thrown error rendered as text — policy context you
authored, never request payload. A CancellationError thrown inside a filter
passes through as cancellation, not as a refusal. For what a failed turn
leaves behind, see when it fails.
When the filter engages
Engagement is keyed on one capability: the provider's requiresNetworking.
Not on which provider it is.
| Provider | Engages the filter |
|---|---|
| Apple on-device (vs Private Cloud Compute, below) | never — nothing leaves the device |
| Anthropic, OpenAI, Gemini | every request |
| your backend, AgentKit Cloud | every request |
| Apple Private Cloud Compute | every request |
One agent configuration works everywhere: with the on-device model the filter sits idle, and swapping in any networked provider — including Apple's own Private Cloud Compute — engages it with no code change.
The wire, not your history
The filter affects only the outgoing request. Your local conversation keeps
the original, unredacted content — each request is built fresh from that
history and filtered again on its way out. Redaction never destroys local
data, and a filter you tighten later applies to the full history on the next
request.
Images are not text
Messages can carry raw image bytes — MessageContent.image on user messages,
and image content inside tool results. A filter that pattern-matches only
.text passes those bytes to the network untouched. If your policy covers
visual content, handle the .image case explicitly: strip the item, replace
it, or throw.
On-device content can still travel
Content that never left the device under the on-device model can leave it later, three ways:
- Resuming recorded history against a cloud provider. Tool calls and results recorded on-device persist into conversation history, and resuming that conversation against a networked provider serializes them to it — see conversations.
- Switching to Private Cloud Compute. The PCC configuration sends conversation content to Apple's servers on every turn — see on-device with Apple.
- Encoding the conversation to disk or an export. A
ConversationisCodable, and encoding one writes on-device tool output — and the absolute file paths behind anyImageRef.fileURL— into the serialized form. This path is not networking-gated, so a pre-transmit filter never runs against it; redaction is the host's responsibility at the encode boundary — see conversations.
The first two destinations require networking, so a configured filter engages and sees that history before it ships. If on-device tool output is too sensitive to ever transmit — or to write to a stored conversation — scope the tools accordingly rather than relying on filtering after the fact.