Privatemode reference instance

May 15, 2026 · View on GitHub

Reference documentation for running Wirken against the Privatemode 2.0 confidential-inference proxy. Everything described here is verified on the current wirken binary; aspirational sections that did not survive contact with a live run were removed. Claims about Privatemode itself (model capabilities, endpoint shapes, release history) are sourced from Privatemode's docs at https://docs.privatemode.ai/ and the public source at https://github.com/edgelesssys/privatemode-public.

Quickstart

One sequential procedure to get a working Wirken + Privatemode instance. Assumes you already have Docker and a built wirken binary. Every step runs locally.

  1. Get a Privatemode access key. Sign up at https://www.privatemode.ai and generate an API key from the dashboard.
  2. Start the proxy on loopback. The upstream image binds 0.0.0.0 by default; the -p 127.0.0.1:… form below constrains that to local traffic only:
    docker run -d --name privatemode-proxy \
      -p 127.0.0.1:8080:8080 \
      ghcr.io/edgelesssys/privatemode/privatemode-proxy:latest \
      --apiKey <YOUR_ACCESS_KEY>
    
    Confirm it's up: curl -s http://127.0.0.1:8080/v1/models | head. You should see JSON, not an error.
  3. Configure Wirken. wirken setup and choose:
    • Step 1 (provider): pick Privatemode. Accept the default proxy URL (http://localhost:8080). Paste your access key. Accept the default model (kimi-k2.5).
    • Step 2 (channel): pick at least one — Telegram or Signal is easiest for a first run.
  4. Start wirken. wirken run. You should see Provider: openai/kimi-k2.5 and your channel listed as a route.
  5. Send a test message through the channel you configured. The response should come back in the same channel.
  6. Verify the round-trip landed in the audit log.
    wirken sessions list              # find the session id
    wirken sessions verify <id>       # exits 0 on an intact hash chain
    
    The session log records one LlmRequest event per turn, carrying provider: "openai" and model: "kimi-k2.5" — that is Wirken's record that the turn went through Privatemode.

If any step fails, see Troubleshooting. For a mixed-provider agent (e.g., Privatemode on Signal, Anthropic on Slack), see Route a single channel to Privatemode instead of step 3.

What works

wirken setup picks Privatemode and writes a provider.json pointing at the local proxy. wirken run opens an OpenAI-shape client at POST http://localhost:8080/v1/chat/completions. The access key lives encrypted in the XChaCha20-Poly1305 vault. Default model is kimi-k2.5; context window and tool support depend on the chosen model — see the Models table.

End-to-end verified: message arrives on a channel adapter → agent runs → outbound request hits the local Privatemode proxy → response routes back through the channel. Every turn writes one LlmRequest event to the hash-chained session log, and wirken sessions verify walks the chain.

Architecture

Wirken and Privatemode

Three trust zones, three enforcement mechanisms.

  1. Channels do not trust each other. Enforcement: per-channel process isolation via Rust phantom types.
  2. Tool execution is not trusted. Enforcement: configurable sandbox — exec-only (default since 0.7.5), gvisor, or off. Mode is read from ~/.wirken/sandbox.json. See docs/security-properties.md for the full matrix.
  3. Inference operator is not trusted. Enforcement: confidential-computing attestation inside the Privatemode local proxy. The proxy fetches a signed manifest from cdn.confidential.cloud:443, validates the remote TEE attestation (AMD SEV-SNP, Intel TDX, NVIDIA H100 CC per the Privatemode docs), and establishes an end-to-end encrypted channel before forwarding any client request. Wirken trusts the proxy handshake — it does not independently verify the attestation.

Stacked, the only parties who see plaintext are the end user and the TEE.

Request flow

  1. Message arrives at a channel adapter. Adapter authenticates to core with Ed25519.
  2. Core loads channel-scoped credentials from the vault.
  3. Skills run in the configured sandbox (exec-only by default) if tools are invoked.
  4. Core emits an inference request to privatemode-proxy at localhost:8080.
  5. Proxy has already attested the Privatemode backend. It encrypts the request, ships it to api.privatemode.ai:443, receives the encrypted response.
  6. Proxy returns plaintext to Wirken.
  7. Wirken routes the agent output back through the originating channel adapter.
  8. Every transition is appended to the hash-chained audit log.

API shape

Privatemode exposes both Anthropic-compatible and OpenAI-compatible surfaces on the same proxy. Privatemode does not designate either as primary — the choice is Wirken's.

ShapeProxy pathSupports
OpenAIPOST /v1/chat/completionsChat, tools, streaming
AnthropicPOST /v1/messagesChat, streaming (SSE), system prompt, max_tokens required
OpenAIPOST /v1/embeddingsqwen3-embedding-4b only
OpenAIPOST /v1/audio/transcriptionswhisper-large-v3, voxtral-mini-3b

All requests use Content-Type: application/json. The --apiKey flag on the proxy authenticates the proxy-to-backend leg.

Wirken uses the OpenAI shape (POST /v1/chat/completions). Implementation in crates/agent/src/llm.rs::privatemode(). Anthropic-shape and embeddings / transcription endpoints are exposed by the same proxy and documented upstream; Wirken does not send to them today.

Models

Verified against https://docs.privatemode.ai/models/overview/ as of Privatemode v1.38.0.

Model IDContextToolsVisionStatus
kimi-k2.5262kYesYesStable
gemma-3-27b128kYes¹YesStable
gpt-oss-120b128kYesNoStable
qwen3-coder-30b-a3b128kYesNoDeprecated — migrate to kimi-k2.5
qwen3-embedding-4b32kStable (embeddings, 1024 or 2560 dim)

¹ Gemma 3 cannot generate mixed text and tool outputs in the same response.

Wirken's default: kimi-k2.5. Matches Privatemode's recommended flagship: 262k context, tools, multimodal. Alternates are selectable via model = "..." in the provider config.

Route a single channel to Privatemode

The more interesting configuration — and the Privatemode partnership's headline demo — routes privacy-sensitive channels (Signal, iMessage) to Privatemode while other channels keep using a cheap-or-capable provider (Anthropic, OpenAI) under the same agent identity. One agent, one skill set, one permission surface; provider chosen per line. This is the #60 feature.

Pre-reqs: run the generic Quickstart once to get a default provider wired. Then:

  1. Store the Privatemode key in the vault under its own slot. The wizard would have stored it as privatemode-access-key when Privatemode was your default; if Privatemode is an override, add the slot explicitly:
    echo "<YOUR_ACCESS_KEY>" | wirken credentials add privatemode-access-key --stdin
    
  2. Re-run wirken setup and answer yes to "Configure a per-channel LLM provider override?" When prompted:
    • Channel: signal (or whichever channel you want routed to Privatemode)
    • Provider: privatemode
    • Model: kimi-k2.5
    • Base URL: http://localhost:8080/v1
    • Name this key: privatemode-access-key
  3. Inspect ~/.wirken/provider.json. It should now contain a channel_overrides block:
    {
      "provider": "anthropic",
      "model": "claude-sonnet-4-6",
      "channel_overrides": {
        "signal": {
          "provider": "privatemode",
          "model": "kimi-k2.5",
          "base_url": "http://localhost:8080/v1",
          "api_key_name": "privatemode-access-key"
        }
      }
    }
    
  4. wirken run. A Signal message now routes to Privatemode; a Slack message stays on the agent's default. The audit log's LlmRequest events carry the per-turn provider and model.

Fail-closed behavior: if api_key_name points at a key that doesn't exist, wirken run refuses to start with a message naming the missing key. Nothing silently degrades to the default.

Credentials

The Privatemode access key is stored in Wirken's XChaCha20-Poly1305 vault as a single inference credential (privatemode-access-key when used as a channel override; otherwise the default <provider>-api-key slot). The operator delivers the key to the proxy via --apiKey on the docker run command line — that leg is proxy ↔ backend, not Wirken's surface.

Deployment

Wirken does not manage the proxy lifecycle. Operators start privatemode-proxy themselves. The wirken setup wizard prints the reference command:

docker run -d --name privatemode-proxy \
  -p 127.0.0.1:8080:8080 \
  ghcr.io/edgelesssys/privatemode/privatemode-proxy:latest \
  --apiKey <key>

Security note: the proxy binary binds 0.0.0.0 by default. The -p 127.0.0.1:8080:8080 form above restricts the published port to loopback so it is not reachable from other hosts. Do not drop the 127.0.0.1: prefix on a multi-tenant or internet-exposed host.

Under systemd, run the same docker invocation from a user unit, or point ExecStart= at the binary directly. Under Kubernetes, use Privatemode's upstream Helm chart at privatemode-proxy/charts/privatemode-proxy/ in https://github.com/edgelesssys/privatemode-public. Wirken does not ship unit files or charts of its own.

Run with manifest auto-fetch enabled (the default). Pinning via --manifestPath is not recommended for production per the upstream guide.

Verifying a round-trip

  1. Start the proxy (see Quickstart step 2).
  2. wirken run with Privatemode configured — either as the default provider or as a channel override.
  3. Send a message through a configured channel.
  4. Confirm the agent response arrives in that channel.
  5. wirken sessions list shows the session. wirken sessions verify <id> walks the hash-chained session log and exits 0 on an intact chain.
  6. Each LLM turn writes one LlmRequest event into the session log carrying provider and model. For Privatemode turns the provider is openai (because Privatemode ships OpenAI-shape today) and the model is kimi-k2.5. A surfaced per-turn view in the CLI is out of scope here — operators who want to confirm directly can query the session log via sqlite3 ~/.wirken/audit.db "SELECT payload FROM session_events WHERE session_id = '<id>' AND event_type = 'LlmRequest'".

Troubleshooting

  • curl http://127.0.0.1:8080/v1/models times out or is refused. The proxy container isn't up. docker ps | grep privatemode-proxy — if absent, re-run step 2 of the Quickstart. If present but unhealthy, docker logs privatemode-proxy; the common first-run cause is attestation against cdn.confidential.cloud:443 being blocked by an outbound firewall.
  • Proxy logs say "401" or "invalid api key". The key you passed to --apiKey is wrong. Generate a fresh one at https://www.privatemode.ai and restart the container. This is a proxy-to-backend error, not a Wirken error — wirken run will print success, then every message will fail at inference.
  • wirken run aborts with "no key named X". You configured a channel override but never ran wirken credentials add X --stdin to create the key. The message names the exact key and the fix.
  • wirken run starts but messages get no reply. Check two things in order: (a) docker logs privatemode-proxy for proxy-side errors, (b) tail -f ~/.wirken/audit.db-backed session events or wirken sessions list to see if an LlmRequest event was even emitted. If no event, the adapter never reached the agent — usually an adapter config problem. If the event is there but no response, the call to the proxy failed; proxy logs will say why.
  • First message after a cold start takes 10+ seconds. The proxy fetches a signed manifest from cdn.confidential.cloud:443 and runs the remote attestation handshake on first request. Second and subsequent requests are fast. Not a bug.
  • I see two privatemode-proxy containers listening on the same port. docker ps, kill the extra one. Wirken does not manage the proxy lifecycle — it assumes the operator owns :8080.

Verified claims

Grounded in the Privatemode 2.0 launch email (2026-04-22) and the public release notes.

  • Privatemode supports Anthropic /v1/messages as of release v1.37.0.
  • Privatemode 2.0 bundles Kimi K2.5, the browser web app, and separate input/output/cached token pricing. The desktop app is deprecated in favor of the web app.
  • Proxy attestation happens before Wirken sends any request. The proxy writes manifest transitions to log.txt in its workspace.
  • gpt-oss-120b is stable with 128k context and tool calling (text-only, no vision).
  • kimi-k2.5 shipped in v1.38.0 (2026-04-22) with 262k context and multimodal input.

Known gotchas

  • reasoning_content deprecation: removed from Anthropic-shape responses on 2026-05-01. If Wirken's audit writer records reasoning_content directly, it must switch to the supported field before that date.
  • Client-version cutoff: Privatemode backends dropped support for clients older than v1.33 on 2026-04-17. Pin the proxy image to :latest or a release ≥ v1.33.
  • Cache token reporting: Anthropic-shape responses from Privatemode do not return cache_creation_input_tokens separately; those tokens are folded into input_tokens. Audit records must not assume the field is present.
  • No Rust SDK: Privatemode ships official SDKs at sdk/js and sdk/wasm in the public repo. Wirken speaks the documented HTTP API directly (reqwest against the local proxy).
  • Default bind: the proxy binary binds 0.0.0.0 — loopback-only exposure is the deployer's responsibility via port publication (docker -p 127.0.0.1:...) or systemd socket activation.

Gaps

  • Wirken does not verify Privatemode attestation independently; it trusts the proxy handshake.
  • No integration test in CI exercises the Privatemode code path. The adapter + LLM client paths have unit coverage, but the end-to-end request/response with the real proxy (or a stub) is verified by hand.
  • The privatemode-access-key credential is per-agent, not per-caller. Multi-user deployments that want to bill inference to distinct humans need per-caller key scoping that does not exist today.

References