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 Conversation is Codable, and encoding one writes on-device tool output — and the absolute file paths behind any ImageRef.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.