From a745a705197dff29452f55a8d8889d6c1870cdab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:36:05 +0000 Subject: [PATCH 1/4] Initial plan From 9d1ed44b464d8c108147d3ce3562712f1025dff8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:49:04 +0000 Subject: [PATCH 2/4] docs: add iOS managed app provider quick-connect guide Co-authored-by: deankroker <9809445+deankroker@users.noreply.github.com> --- docs/docs.json | 376 ++++++++++----- docs/install/ios-managed-app.md | 779 ++++++++++++++++++++++++++++++++ 2 files changed, 1034 insertions(+), 121 deletions(-) create mode 100644 docs/install/ios-managed-app.md diff --git a/docs/docs.json b/docs/docs.json index 31dfee49c2f46..27a3751a66604 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -826,15 +826,21 @@ "groups": [ { "group": "Home", - "pages": ["index"] + "pages": [ + "index" + ] }, { "group": "Overview", - "pages": ["start/showcase"] + "pages": [ + "start/showcase" + ] }, { "group": "Core concepts", - "pages": ["concepts/features"] + "pages": [ + "concepts/features" + ] }, { "group": "First steps", @@ -860,7 +866,10 @@ "groups": [ { "group": "Install overview", - "pages": ["install/index", "install/installer"] + "pages": [ + "install/index", + "install/installer" + ] }, { "group": "Other install methods", @@ -874,13 +883,18 @@ }, { "group": "Maintenance", - "pages": ["install/updating", "install/migrating", "install/uninstall"] + "pages": [ + "install/updating", + "install/migrating", + "install/uninstall" + ] }, { "group": "Hosting and deployment", "pages": [ "vps", "install/kubernetes", + "install/ios-managed-app", "install/fly", "install/hetzner", "install/gcp", @@ -893,7 +907,9 @@ }, { "group": "Advanced", - "pages": ["install/development-channels"] + "pages": [ + "install/development-channels" + ] } ] }, @@ -902,7 +918,9 @@ "groups": [ { "group": "Overview", - "pages": ["channels/index"] + "pages": [ + "channels/index" + ] }, { "group": "Messaging platforms", @@ -963,7 +981,9 @@ }, { "group": "Bootstrapping", - "pages": ["start/bootstrapping"] + "pages": [ + "start/bootstrapping" + ] }, { "group": "Sessions and memory", @@ -977,7 +997,10 @@ }, { "group": "Multi-agent", - "pages": ["concepts/multi-agent", "concepts/presence"] + "pages": [ + "concepts/multi-agent", + "concepts/presence" + ] }, { "group": "Messages and delivery", @@ -995,7 +1018,9 @@ "groups": [ { "group": "Overview", - "pages": ["tools/index"] + "pages": [ + "tools/index" + ] }, { "group": "Built-in tools", @@ -1093,15 +1118,23 @@ "groups": [ { "group": "Overview", - "pages": ["providers/index", "providers/models"] + "pages": [ + "providers/index", + "providers/models" + ] }, { "group": "Model concepts", - "pages": ["concepts/models"] + "pages": [ + "concepts/models" + ] }, { "group": "Configuration", - "pages": ["concepts/model-providers", "concepts/model-failover"] + "pages": [ + "concepts/model-providers", + "concepts/model-failover" + ] }, { "group": "Providers", @@ -1240,7 +1273,11 @@ }, { "group": "Remote access", - "pages": ["gateway/remote", "gateway/remote-gateway-readme", "gateway/tailscale"] + "pages": [ + "gateway/remote", + "gateway/remote-gateway-readme", + "gateway/tailscale" + ] }, { "group": "Security", @@ -1252,7 +1289,13 @@ }, { "group": "Web interfaces", - "pages": ["web/index", "web/control-ui", "web/dashboard", "web/webchat", "web/tui"] + "pages": [ + "web/index", + "web/control-ui", + "web/dashboard", + "web/webchat", + "web/tui" + ] } ] }, @@ -1312,7 +1355,10 @@ }, { "group": "RPC and API", - "pages": ["reference/rpc", "reference/device-models"] + "pages": [ + "reference/rpc", + "reference/device-models" + ] }, { "group": "Templates", @@ -1352,11 +1398,16 @@ }, { "group": "Project", - "pages": ["reference/credits"] + "pages": [ + "reference/credits" + ] }, { "group": "Release policy", - "pages": ["reference/RELEASING", "reference/test"] + "pages": [ + "reference/RELEASING", + "reference/test" + ] }, { "group": "Experiments", @@ -1380,11 +1431,17 @@ "groups": [ { "group": "Help", - "pages": ["help/index", "help/troubleshooting", "help/faq"] + "pages": [ + "help/index", + "help/troubleshooting", + "help/faq" + ] }, { "group": "Community", - "pages": ["start/lore"] + "pages": [ + "start/lore" + ] }, { "group": "Environment and debugging", @@ -1399,23 +1456,35 @@ }, { "group": "Node runtime", - "pages": ["install/node"] + "pages": [ + "install/node" + ] }, { "group": "Compaction internals", - "pages": ["reference/session-management-compaction"] + "pages": [ + "reference/session-management-compaction" + ] }, { "group": "Developer setup", - "pages": ["start/setup", "pi-dev"] + "pages": [ + "start/setup", + "pi-dev" + ] }, { "group": "Contributing", - "pages": ["ci"] + "pages": [ + "ci" + ] }, { "group": "Docs meta", - "pages": ["start/hubs", "start/docs-directory"] + "pages": [ + "start/hubs", + "start/docs-directory" + ] } ] } @@ -1425,22 +1494,28 @@ "language": "zh-Hans", "tabs": [ { - "tab": "快速开始", + "tab": "\u5feb\u901f\u5f00\u59cb", "groups": [ { - "group": "首页", - "pages": ["zh-CN/index"] + "group": "\u9996\u9875", + "pages": [ + "zh-CN/index" + ] }, { - "group": "概览", - "pages": ["zh-CN/start/showcase"] + "group": "\u6982\u89c8", + "pages": [ + "zh-CN/start/showcase" + ] }, { - "group": "核心概念", - "pages": ["zh-CN/concepts/features"] + "group": "\u6838\u5fc3\u6982\u5ff5", + "pages": [ + "zh-CN/concepts/features" + ] }, { - "group": "第一步", + "group": "\u7b2c\u4e00\u6b65", "pages": [ "zh-CN/start/getting-started", "zh-CN/start/wizard", @@ -1448,20 +1523,25 @@ ] }, { - "group": "指南", - "pages": ["zh-CN/start/openclaw"] + "group": "\u6307\u5357", + "pages": [ + "zh-CN/start/openclaw" + ] } ] }, { - "tab": "安装", + "tab": "\u5b89\u88c5", "groups": [ { - "group": "安装概览", - "pages": ["zh-CN/install/index", "zh-CN/install/installer"] + "group": "\u5b89\u88c5\u6982\u89c8", + "pages": [ + "zh-CN/install/index", + "zh-CN/install/installer" + ] }, { - "group": "安装方式", + "group": "\u5b89\u88c5\u65b9\u5f0f", "pages": [ "zh-CN/install/docker", "zh-CN/install/nix", @@ -1470,7 +1550,7 @@ ] }, { - "group": "维护", + "group": "\u7ef4\u62a4", "pages": [ "zh-CN/install/updating", "zh-CN/install/migrating", @@ -1478,7 +1558,7 @@ ] }, { - "group": "托管与部署", + "group": "\u6258\u7ba1\u4e0e\u90e8\u7f72", "pages": [ "zh-CN/vps", "zh-CN/install/fly", @@ -1492,20 +1572,24 @@ ] }, { - "group": "高级", - "pages": ["zh-CN/install/development-channels"] + "group": "\u9ad8\u7ea7", + "pages": [ + "zh-CN/install/development-channels" + ] } ] }, { - "tab": "消息渠道", + "tab": "\u6d88\u606f\u6e20\u9053", "groups": [ { - "group": "概览", - "pages": ["zh-CN/channels/index"] + "group": "\u6982\u89c8", + "pages": [ + "zh-CN/channels/index" + ] }, { - "group": "消息平台", + "group": "\u6d88\u606f\u5e73\u53f0", "pages": [ "zh-CN/channels/bluebubbles", "zh-CN/channels/discord", @@ -1530,7 +1614,7 @@ ] }, { - "group": "配置", + "group": "\u914d\u7f6e", "pages": [ "zh-CN/channels/pairing", "zh-CN/channels/group-messages", @@ -1544,10 +1628,10 @@ ] }, { - "tab": "代理", + "tab": "\u4ee3\u7406", "groups": [ { - "group": "基础", + "group": "\u57fa\u7840", "pages": [ "zh-CN/pi", "zh-CN/concepts/architecture", @@ -1560,11 +1644,13 @@ ] }, { - "group": "引导", - "pages": ["zh-CN/start/bootstrapping"] + "group": "\u5f15\u5bfc", + "pages": [ + "zh-CN/start/bootstrapping" + ] }, { - "group": "会话与记忆", + "group": "\u4f1a\u8bdd\u4e0e\u8bb0\u5fc6", "pages": [ "zh-CN/concepts/session", "zh-CN/concepts/session-pruning", @@ -1574,11 +1660,14 @@ ] }, { - "group": "多代理", - "pages": ["zh-CN/concepts/multi-agent", "zh-CN/concepts/presence"] + "group": "\u591a\u4ee3\u7406", + "pages": [ + "zh-CN/concepts/multi-agent", + "zh-CN/concepts/presence" + ] }, { - "group": "消息与投递", + "group": "\u6d88\u606f\u4e0e\u6295\u9012", "pages": [ "zh-CN/concepts/messages", "zh-CN/concepts/streaming", @@ -1589,14 +1678,16 @@ ] }, { - "tab": "工具", + "tab": "\u5de5\u5177", "groups": [ { - "group": "概览", - "pages": ["zh-CN/tools/index"] + "group": "\u6982\u89c8", + "pages": [ + "zh-CN/tools/index" + ] }, { - "group": "内置工具", + "group": "\u5185\u7f6e\u5de5\u5177", "pages": [ "zh-CN/tools/apply-patch", "zh-CN/brave-search", @@ -1613,7 +1704,7 @@ ] }, { - "group": "浏览器", + "group": "\u6d4f\u89c8\u5668", "pages": [ "zh-CN/tools/browser", "zh-CN/tools/browser-login", @@ -1621,7 +1712,7 @@ ] }, { - "group": "代理协作", + "group": "\u4ee3\u7406\u534f\u4f5c", "pages": [ "zh-CN/tools/agent-send", "zh-CN/tools/subagents", @@ -1629,7 +1720,7 @@ ] }, { - "group": "技能", + "group": "\u6280\u80fd", "pages": [ "zh-CN/tools/creating-skills", "zh-CN/tools/slash-commands", @@ -1640,7 +1731,7 @@ ] }, { - "group": "扩展", + "group": "\u6269\u5c55", "pages": [ "zh-CN/plugins/voice-call", "zh-CN/plugins/zalouser", @@ -1650,7 +1741,7 @@ ] }, { - "group": "自动化", + "group": "\u81ea\u52a8\u5316", "pages": [ "zh-CN/automation/hooks", "zh-CN/automation/cron-jobs", @@ -1663,7 +1754,7 @@ ] }, { - "group": "媒体与设备", + "group": "\u5a92\u4f53\u4e0e\u8bbe\u5907", "pages": [ "zh-CN/nodes/index", "zh-CN/nodes/troubleshooting", @@ -1680,22 +1771,30 @@ ] }, { - "tab": "模型", + "tab": "\u6a21\u578b", "groups": [ { - "group": "概览", - "pages": ["zh-CN/providers/index", "zh-CN/providers/models"] + "group": "\u6982\u89c8", + "pages": [ + "zh-CN/providers/index", + "zh-CN/providers/models" + ] }, { - "group": "模型概念", - "pages": ["zh-CN/concepts/models"] + "group": "\u6a21\u578b\u6982\u5ff5", + "pages": [ + "zh-CN/concepts/models" + ] }, { - "group": "配置", - "pages": ["zh-CN/concepts/model-providers", "zh-CN/concepts/model-failover"] + "group": "\u914d\u7f6e", + "pages": [ + "zh-CN/concepts/model-providers", + "zh-CN/concepts/model-failover" + ] }, { - "group": "提供商", + "group": "\u63d0\u4f9b\u5546", "pages": [ "zh-CN/providers/anthropic", "zh-CN/providers/bedrock", @@ -1721,10 +1820,10 @@ ] }, { - "tab": "平台", + "tab": "\u5e73\u53f0", "groups": [ { - "group": "平台概览", + "group": "\u5e73\u53f0\u6982\u89c8", "pages": [ "zh-CN/platforms/index", "zh-CN/platforms/macos", @@ -1738,7 +1837,7 @@ ] }, { - "group": "macOS 配套应用", + "group": "macOS \u914d\u5957\u5e94\u7528", "pages": [ "zh-CN/platforms/mac/dev-setup", "zh-CN/platforms/mac/menu-bar", @@ -1762,14 +1861,14 @@ ] }, { - "tab": "网关与运维", + "tab": "\u7f51\u5173\u4e0e\u8fd0\u7ef4", "groups": [ { - "group": "网关", + "group": "\u7f51\u5173", "pages": [ "zh-CN/gateway/index", { - "group": "配置与运维", + "group": "\u914d\u7f6e\u4e0e\u8fd0\u7ef4", "pages": [ "zh-CN/gateway/configuration", "zh-CN/gateway/configuration-examples", @@ -1785,7 +1884,7 @@ ] }, { - "group": "安全与沙箱", + "group": "\u5b89\u5168\u4e0e\u6c99\u7bb1", "pages": [ "zh-CN/gateway/security/index", "zh-CN/gateway/sandboxing", @@ -1793,7 +1892,7 @@ ] }, { - "group": "协议与 API", + "group": "\u534f\u8bae\u4e0e API", "pages": [ "zh-CN/gateway/protocol", "zh-CN/gateway/bridge-protocol", @@ -1805,7 +1904,7 @@ ] }, { - "group": "网络与发现", + "group": "\u7f51\u7edc\u4e0e\u53d1\u73b0", "pages": [ "zh-CN/gateway/network-model", "zh-CN/gateway/pairing", @@ -1816,7 +1915,7 @@ ] }, { - "group": "远程访问", + "group": "\u8fdc\u7a0b\u8bbf\u95ee", "pages": [ "zh-CN/gateway/remote", "zh-CN/gateway/remote-gateway-readme", @@ -1824,15 +1923,20 @@ ] }, { - "group": "运维专题", - "pages": ["zh-CN/network", "zh-CN/logging"] + "group": "\u8fd0\u7ef4\u4e13\u9898", + "pages": [ + "zh-CN/network", + "zh-CN/logging" + ] }, { - "group": "安全", - "pages": ["zh-CN/security/formal-verification"] + "group": "\u5b89\u5168", + "pages": [ + "zh-CN/security/formal-verification" + ] }, { - "group": "Web 界面", + "group": "Web \u754c\u9762", "pages": [ "zh-CN/web/index", "zh-CN/web/control-ui", @@ -1844,10 +1948,10 @@ ] }, { - "tab": "参考", + "tab": "\u53c2\u8003", "groups": [ { - "group": "CLI 命令", + "group": "CLI \u547d\u4ee4", "pages": [ "zh-CN/cli/index", "zh-CN/cli/acp", @@ -1893,11 +1997,14 @@ ] }, { - "group": "RPC 与 API", - "pages": ["zh-CN/reference/rpc", "zh-CN/reference/device-models"] + "group": "RPC \u4e0e API", + "pages": [ + "zh-CN/reference/rpc", + "zh-CN/reference/device-models" + ] }, { - "group": "模板", + "group": "\u6a21\u677f", "pages": [ "zh-CN/reference/AGENTS.default", "zh-CN/reference/templates/AGENTS", @@ -1911,7 +2018,7 @@ ] }, { - "group": "技术参考", + "group": "\u6280\u672f\u53c2\u8003", "pages": [ "zh-CN/reference/wizard", "zh-CN/reference/token-use", @@ -1921,7 +2028,7 @@ ] }, { - "group": "概念内部机制", + "group": "\u6982\u5ff5\u5185\u90e8\u673a\u5236", "pages": [ "zh-CN/concepts/typebox", "zh-CN/concepts/markdown-formatting", @@ -1931,15 +2038,20 @@ ] }, { - "group": "项目", - "pages": ["zh-CN/reference/credits"] + "group": "\u9879\u76ee", + "pages": [ + "zh-CN/reference/credits" + ] }, { - "group": "发布策略", - "pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"] + "group": "\u53d1\u5e03\u7b56\u7565", + "pages": [ + "zh-CN/reference/RELEASING", + "zh-CN/reference/test" + ] }, { - "group": "实验性功能", + "group": "\u5b9e\u9a8c\u6027\u529f\u80fd", "pages": [ "zh-CN/experiments/onboarding-config-protocol", "zh-CN/experiments/plans/openresponses-gateway", @@ -1950,7 +2062,7 @@ ] }, { - "group": "重构方案", + "group": "\u91cd\u6784\u65b9\u6848", "pages": [ "zh-CN/refactor/clawnet", "zh-CN/refactor/exec-host", @@ -1962,18 +2074,24 @@ ] }, { - "tab": "帮助", + "tab": "\u5e2e\u52a9", "groups": [ { - "group": "帮助", - "pages": ["zh-CN/help/index", "zh-CN/help/troubleshooting", "zh-CN/help/faq"] + "group": "\u5e2e\u52a9", + "pages": [ + "zh-CN/help/index", + "zh-CN/help/troubleshooting", + "zh-CN/help/faq" + ] }, { - "group": "社区", - "pages": ["zh-CN/start/lore"] + "group": "\u793e\u533a", + "pages": [ + "zh-CN/start/lore" + ] }, { - "group": "环境与调试", + "group": "\u73af\u5883\u4e0e\u8c03\u8bd5", "pages": [ "zh-CN/help/environment", "zh-CN/help/debugging", @@ -1984,20 +2102,31 @@ ] }, { - "group": "Node 运行时", - "pages": ["zh-CN/install/node"] + "group": "Node \u8fd0\u884c\u65f6", + "pages": [ + "zh-CN/install/node" + ] }, { - "group": "压缩机制内部参考", - "pages": ["zh-CN/reference/session-management-compaction"] + "group": "\u538b\u7f29\u673a\u5236\u5185\u90e8\u53c2\u8003", + "pages": [ + "zh-CN/reference/session-management-compaction" + ] }, { - "group": "开发者设置", - "pages": ["zh-CN/start/setup", "zh-CN/pi-dev"] + "group": "\u5f00\u53d1\u8005\u8bbe\u7f6e", + "pages": [ + "zh-CN/start/setup", + "zh-CN/pi-dev" + ] }, { - "group": "文档元信息", - "pages": ["zh-CN/start/hubs", "zh-CN/start/docs-directory", "zh-CN/AGENTS"] + "group": "\u6587\u6863\u5143\u4fe1\u606f", + "pages": [ + "zh-CN/start/hubs", + "zh-CN/start/docs-directory", + "zh-CN/AGENTS" + ] } ] } @@ -2007,15 +2136,20 @@ "language": "ja", "tabs": [ { - "tab": "はじめに", + "tab": "\u306f\u3058\u3081\u306b", "groups": [ { - "group": "概要", - "pages": ["ja-JP/index"] + "group": "\u6982\u8981", + "pages": [ + "ja-JP/index" + ] }, { - "group": "初回セットアップ", - "pages": ["ja-JP/start/getting-started", "ja-JP/start/wizard"] + "group": "\u521d\u56de\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7", + "pages": [ + "ja-JP/start/getting-started", + "ja-JP/start/wizard" + ] } ] } diff --git a/docs/install/ios-managed-app.md b/docs/install/ios-managed-app.md new file mode 100644 index 0000000000000..b34bdf543144a --- /dev/null +++ b/docs/install/ios-managed-app.md @@ -0,0 +1,779 @@ +--- +summary: "Per-provider guide to implementing quick connect in a managed OpenClaw iOS app" +read_when: + - You are building an iOS app that manages OpenClaw containers on behalf of users + - You need to implement provider authentication from a native iOS interface + - You want per-provider quick-connect patterns (API key, OAuth, setup-token, AWS credentials, endpoint) +title: "iOS Managed App — Provider Quick Connect" +--- + +# iOS Managed App — Provider Quick Connect + +This guide covers how to implement **quick connect** for every supported model provider in an iOS app that manages dedicated OpenClaw containers (e.g., hosted on Azure Kubernetes Service). Users never run the OpenClaw CLI themselves — the iOS app controls their container configuration through the [file API](/install/ios-managed-app#the-file-api-bridge). + +## Architecture recap + +``` +┌─────────────────┐ ┌──────────────┐ ┌──────────────────────────┐ +│ │ │ │ │ Azure Cloud (AKS) │ +│ iOS App │◄──►│ Supabase │ │ │ +│ (SwiftUI) │ │ (Auth + DB) │ │ ┌────────────────────┐ │ +│ │ │ │ │ │ OpenClaw Pod │ │ +│ • Provider UI │ │ • Users │ │ │ (ClusterIP only) │ │ +│ • OAuth sheets │ │ • Containers │ │ └─────────┬──────────┘ │ +│ • Key input │ │ • RLS │ │ │ Azure Files │ +│ │ │ │ │ ┌─────────┴──────────┐ │ +└────────┬────────┘ └──────────────┘ │ │ file-api pod │ │ + │ │ │ (Cloudflare Tunnel)│ │ + │ HTTPS + Supabase JWT │ └────────────────────┘ │ + └──▶ files.spark.ooo ─────────────┘ │ + └──────────────────────────┘ +``` + +## The file-API bridge + +The file-api pod (`https://files.spark.ooo`) is the control plane between the iOS app and the user's OpenClaw container. It: + +- Authenticates every request using the Supabase JWT from the iOS app. +- Reads and writes files in the user's mounted Azure Files share (the same share OpenClaw mounts). +- Exposes endpoints for browsing, reading, writing, and deleting files — including `openclaw.json` (the OpenClaw gateway config). + +### Writing provider credentials from iOS + +All provider credentials follow the same pattern: + +1. **Collect** the credential in the iOS app UI (text field, OAuth callback, etc.). +2. **Read** the current `openclaw.json` from the file API. +3. **Merge** the new provider config into the JSON (preserve all other config keys). +4. **Write** the updated `openclaw.json` back to the file API. +5. **Signal** the container to reload its config (e.g., `POST /restart` or `PUT /config/reload`, depending on your file-api implementation). + +```swift +// Conceptual Swift helper — adapt to your actual file-api client +func saveProviderKey(envKey: String, value: String) async throws { + var config = try await fileAPI.readOpenClawConfig() + config.env[envKey] = value + try await fileAPI.writeOpenClawConfig(config) + try await fileAPI.reloadGateway() +} +``` + +Config is written to the user's `openclaw.json` as environment variables under the `env` key: + +```json5 +{ + env: { + OPENAI_API_KEY: "sk-...", + ANTHROPIC_API_KEY: "sk-ant-...", + // ... + }, + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } }, +} +``` + +## Connection types + +| Type | Providers | iOS pattern | +|------|-----------|-------------| +| [API key paste](#api-key-providers) | OpenAI, Anthropic, Mistral, Together AI, OpenRouter, Venice, Hugging Face, MiniMax, Moonshot, NVIDIA, GLM, Qianfan, Kilocode, Xiaomi, Z.AI, Deepgram, Vercel AI Gateway | `SecureField` + file-api write | +| [OAuth via browser sheet](#oauth-providers) | GitHub Copilot, OpenAI Codex, Qwen | `ASWebAuthenticationSession` | +| [Device flow](#device-flow-providers) | GitHub Copilot (fallback) | Show code + URL in app | +| [Setup-token paste](#anthropic-claude-subscription--setup-token) | Anthropic (Claude subscription) | `SecureField` + file-api write | +| [AWS credentials](#amazon-bedrock) | Amazon Bedrock | Two `SecureField`s + file-api write | +| [Endpoint URL](#endpoint-based-providers) | Ollama, vLLM, sglang, LiteLLM, Cloudflare AI Gateway | `TextField` + file-api write | + +--- + +## API key providers + +For providers that authenticate with a single secret token, the flow is: + +1. Show a `SecureField` (masked input) for the key. +2. Provide a **"Get your key"** button that opens the provider's dashboard in `SFSafariViewController`. +3. On submit, write `env.PROVIDER_API_KEY` via the file API. +4. Confirm with a brief success banner. + +```swift +struct APIKeyConnectView: View { + let envKey: String // e.g. "OPENAI_API_KEY" + let dashboardURL: URL + @State private var apiKey = "" + + var body: some View { + Form { + Section("API key") { + SecureField("Paste your key", text: $apiKey) + } + Section { + Button("Get your key") { + UIApplication.shared.open(dashboardURL) + } + Button("Connect") { + Task { try await saveProviderKey(envKey: envKey, value: apiKey) } + } + .disabled(apiKey.isEmpty) + } + } + } +} +``` + +### OpenAI + +- **Dashboard:** `https://platform.openai.com/api-keys` +- **Env key:** `OPENAI_API_KEY` +- **Key format:** `sk-...` +- **Recommended model:** `openai/gpt-5.4` + +```swift +APIKeyConnectView( + envKey: "OPENAI_API_KEY", + dashboardURL: URL(string: "https://platform.openai.com/api-keys")! +) +``` + +Config written to `openclaw.json`: + +```json5 +{ + env: { OPENAI_API_KEY: "sk-..." }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, +} +``` + +### Anthropic (API key) + +- **Dashboard:** `https://console.anthropic.com/settings/keys` +- **Env key:** `ANTHROPIC_API_KEY` +- **Key format:** `sk-ant-...` +- **Recommended model:** `anthropic/claude-opus-4-6` + +Config written to `openclaw.json`: + +```json5 +{ + env: { ANTHROPIC_API_KEY: "sk-ant-..." }, + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } }, +} +``` + +See also [Anthropic Claude subscription (setup-token)](#anthropic-claude-subscription--setup-token) if the user has a Claude subscription instead of an API key. + +### Mistral + +- **Dashboard:** `https://console.mistral.ai/api-keys/` +- **Env key:** `MISTRAL_API_KEY` +- **Key format:** `sk-...` +- **Recommended model:** `mistral/mistral-large-latest` + +### Together AI + +- **Dashboard:** `https://api.together.xyz/settings/api-keys` +- **Env key:** `TOGETHER_API_KEY` +- **Recommended model:** `together/moonshotai/Kimi-K2.5` + +### OpenRouter + +- **Dashboard:** `https://openrouter.ai/keys` +- **Env key:** `OPENROUTER_API_KEY` +- **Key format:** `sk-or-...` +- **Recommended model:** `openrouter/anthropic/claude-sonnet-4-5` + +### Venice AI + +- **Dashboard:** `https://venice.ai/settings/api` +- **Env key:** `VENICE_API_KEY` +- **Recommended model:** `venice/llama-3.3-70b` + +### Hugging Face + +- **Dashboard:** `https://huggingface.co/settings/tokens` +- **Token type:** Fine-grained, with **Make calls to Inference Providers** permission +- **Env key:** `HUGGINGFACE_HUB_TOKEN` +- **Recommended model:** `huggingface/meta-llama/Llama-3.3-70B-Instruct` + +### MiniMax + +- **Dashboard:** `https://platform.minimax.chat/user-center/basic-information/interface-key` +- **Env key:** `MINIMAX_API_KEY` + +### Moonshot AI (Kimi) + +- **Dashboard:** `https://platform.moonshot.cn/console/api-keys` +- **Env key:** `MOONSHOT_API_KEY` +- **Recommended model:** `moonshot/moonshot-v1-8k` + +### NVIDIA + +- **Dashboard:** `https://build.nvidia.com/` +- **Env key:** `NVIDIA_API_KEY` +- **Key format:** `nvapi-...` + +### GLM (Zhipu AI) + +- **Dashboard:** `https://open.bigmodel.cn/usercenter/apikeys` +- **Env key:** `GLM_API_KEY` + +### Qianfan (Baidu) + +Qianfan requires both a key and a secret: + +- **Dashboard:** `https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application` +- **Env keys:** `QIANFAN_API_KEY` and `QIANFAN_SECRET_KEY` + +For Qianfan, show two `SecureField`s — one for the Access Key and one for the Secret Key — and write both to `env` in `openclaw.json`. + +### Kilocode + +- **Dashboard:** `https://app.kilo.ai` +- **Env key:** `KILOCODE_API_KEY` +- **Recommended model:** `kilocode/kilo/auto` + +### Xiaomi AI + +- **Dashboard:** `https://ai.xiaomi.com/` +- **Env key:** `XIAOMI_API_KEY` + +### Z.AI + +- **Dashboard:** `https://z.ai/` +- **Env key:** `ZAI_API_KEY` + +### Deepgram (transcription) + +- **Dashboard:** `https://console.deepgram.com/` +- **Env key:** `DEEPGRAM_API_KEY` +- **Key format:** `Token ...` + +### Vercel AI Gateway + +- **Dashboard:** `https://vercel.com/` +- **Env key:** `VERCEL_API_KEY` + +--- + +## OAuth providers + +For OAuth providers, the iOS app opens an in-app browser sheet via `ASWebAuthenticationSession`. After the user authorizes in the browser, the provider redirects to a deep-link URL registered by the iOS app, which carries the token or authorization code. The app then exchanges the code (if needed) and writes the resulting token to the file API. + +### Deep-link registration + +Register a custom URL scheme (e.g., `openclaw://auth/callback`) in your iOS app's `Info.plist`: + +```xml +CFBundleURLTypes + + + CFBundleURLSchemes + + openclaw + + + +``` + +Use this callback URL when constructing OAuth authorize URLs. + +--- + +### OpenAI Codex (ChatGPT subscription) + +OpenAI supports OAuth for ChatGPT/Codex subscribers. The iOS app initiates the standard Authorization Code + PKCE flow. + +**Endpoints:** +- Authorize: `https://auth.openai.com/oauth/authorize` +- Token: `https://auth.openai.com/oauth/token` + +**Scopes:** `openid profile email offline_access` + +**Flow:** + +```swift +import AuthenticationServices + +func connectOpenAICodex() async throws -> String { + let codeVerifier = generatePKCEVerifier() + let codeChallenge = sha256Base64URL(codeVerifier) + + var components = URLComponents(string: "https://auth.openai.com/oauth/authorize")! + components.queryItems = [ + .init(name: "response_type", value: "code"), + .init(name: "client_id", value: YOUR_OPENAI_CLIENT_ID), + .init(name: "redirect_uri", value: "openclaw://auth/callback"), + .init(name: "scope", value: "openid profile email offline_access"), + .init(name: "code_challenge", value: codeChallenge), + .init(name: "code_challenge_method", value: "S256"), + ] + + let code = try await ASWebAuthenticationSession.authenticate( + url: components.url!, + callbackURLScheme: "openclaw" + ) + let accessToken = try await exchangeCodeForToken(code, verifier: codeVerifier, + tokenURL: "https://auth.openai.com/oauth/token") + + // Write the Codex token via file API + var config = try await fileAPI.readOpenClawConfig() + config.setCodexToken(accessToken) // writes to models.json / auth store + try await fileAPI.writeOpenClawConfig(config) + return accessToken +} +``` + +Tokens are stored in the container's auth profile store (not as plain env vars). The container needs a matching `models.providers.openai-codex` entry pointing at the Codex endpoint. + +**Recommended model after auth:** `openai-codex/gpt-5.4` + +--- + +### GitHub Copilot (OAuth — recommended) + +GitHub uses OAuth 2.0 with the device authorization grant (device flow) or with a standard authorization code flow for apps registered in GitHub OAuth Apps. + +#### Authorization code flow (best for iOS) + +Register your iOS app as a GitHub OAuth App at `https://github.com/settings/developers`. Set the callback URL to `openclaw://auth/callback`. + +```swift +func connectGitHubCopilot() async throws { + var components = URLComponents(string: "https://github.com/login/oauth/authorize")! + components.queryItems = [ + .init(name: "client_id", value: YOUR_GITHUB_CLIENT_ID), + .init(name: "scope", value: "read:user"), + .init(name: "redirect_uri", value: "openclaw://auth/callback"), + ] + + let code = try await ASWebAuthenticationSession.authenticate( + url: components.url!, + callbackURLScheme: "openclaw" + ) + let githubToken = try await exchangeGitHubCode(code) + + // OpenClaw exchanges this GitHub token for a Copilot API token at runtime. + // Store the GitHub token in the auth profile store via file API. + var config = try await fileAPI.readOpenClawConfig() + config.setGitHubToken(githubToken) + try await fileAPI.writeOpenClawConfig(config) +} +``` + +**Recommended model after auth:** `github-copilot/gpt-4o` or `github-copilot/gpt-4.1` + +--- + +### GitHub Copilot (device flow — fallback) + +If you cannot register a GitHub OAuth App (e.g., enterprise restrictions), use the device authorization grant. The iOS app polls until the user completes authorization in their browser. + +```swift +struct DeviceFlowConnectView: View { + @State private var userCode: String = "" + @State private var verificationURL: URL? + @State private var isPolling = false + + var body: some View { + VStack(spacing: 20) { + if let url = verificationURL { + Text("Open this URL in your browser:") + Link(url.absoluteString, destination: url) + Text("Then enter this code:") + .font(.headline) + Text(userCode) + .font(.largeTitle.monospaced()) + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + Button("Open in Safari") { + UIApplication.shared.open(url) + } + } else { + ProgressView("Starting device flow...") + } + } + .task { await startDeviceFlow() } + } + + func startDeviceFlow() async { + let response = try! await requestDeviceCodes( + clientID: YOUR_GITHUB_CLIENT_ID, + scope: "read:user" + ) + userCode = response.userCode + verificationURL = URL(string: response.verificationURL) + isPolling = true + let token = try! await pollForToken(deviceCode: response.deviceCode, + interval: response.interval) + // Store token via file API + } +} +``` + +--- + +### Qwen (OAuth) + +Qwen uses a device-code OAuth flow through the Qwen Portal. + +1. Call the Qwen device-code endpoint to get a user code and verification URL. +2. Display the code to the user (same `DeviceFlowConnectView` pattern above). +3. Poll until the user completes auth on the Qwen Portal website. +4. Store the resulting access token and refresh token via the file API. + +```swift +// After obtaining the Qwen token: +var config = try await fileAPI.readOpenClawConfig() +config.setQwenToken(accessToken: token.accessToken, refreshToken: token.refreshToken) +try await fileAPI.writeOpenClawConfig(config) +``` + +The Qwen plugin (`qwen-portal-auth`) must be enabled on the OpenClaw container before the token is useful. Enable it via the file API by writing the plugin config: + +```json5 +{ + plugins: { enabled: ["qwen-portal-auth"] }, +} +``` + +**Recommended models after auth:** +- `qwen-portal/coder-model` +- `qwen-portal/vision-model` + +--- + +## Anthropic Claude subscription — setup-token + +Users with a Claude subscription (Pro, Team, or Enterprise) can authenticate using a **setup-token** generated by the Claude Code CLI. This is a paste-based flow — the user generates the token on a desktop, copies it, then pastes it in the iOS app. + +**UI pattern:** + +1. Show a `SecureField` for the setup-token. +2. Show instructions: _"On your Mac or PC, install the Claude CLI and run `claude setup-token`. Copy the token and paste it here."_ +3. On submit, store the token via the file API in the container's auth profile store (not as a plain env var — setup-tokens have a dedicated storage path). + +```swift +struct ClaudeSetupTokenView: View { + @State private var token = "" + + var body: some View { + Form { + Section("Setup-token") { + SecureField("Paste setup-token", text: $token) + } + Section { + VStack(alignment: .leading, spacing: 8) { + Text("How to get a setup-token:") + .font(.headline) + Text("1. Install the Claude CLI on your Mac or PC.") + Text("2. Run `claude setup-token` in a terminal.") + Text("3. Copy the token that appears.") + Text("4. Paste it above.") + } + .font(.callout) + .foregroundStyle(.secondary) + } + Section { + Button("Connect") { + Task { try await saveSetupToken(token) } + } + .disabled(token.isEmpty) + } + } + } +} +``` + +The setup-token is written to the container's `models.auth.profiles` store (via file API) rather than to `env`. The container's OpenClaw process reads it at startup and uses it to refresh OAuth tokens automatically. + +**Recommended model after auth:** `anthropic/claude-opus-4-6` + +--- + +## Amazon Bedrock + +Amazon Bedrock uses AWS credentials rather than a simple API key. For a managed iOS app, the safest approach is to collect the user's **AWS Access Key ID** and **AWS Secret Access Key** (optionally with a **Session Token** for temporary credentials) and store them in the container's environment. + + +Do not use long-lived IAM user credentials for production deployments. Prefer IAM roles with restricted Bedrock permissions attached to the AKS pod service account via IRSA or Azure Workload Identity Federation. If you must collect credentials from the iOS app, use short-lived credentials generated by AWS STS or your own credential broker. + + +**UI pattern:** + +```swift +struct BedrockConnectView: View { + @State private var accessKeyID = "" + @State private var secretAccessKey = "" + @State private var sessionToken = "" + @State private var region = "us-east-1" + + var body: some View { + Form { + Section("AWS Credentials") { + TextField("Access Key ID", text: $accessKeyID) + .autocorrectionDisabled() + SecureField("Secret Access Key", text: $secretAccessKey) + SecureField("Session Token (optional)", text: $sessionToken) + } + Section("Region") { + TextField("AWS Region", text: $region) + } + Section { + Button("Connect") { + Task { try await saveBedrockCredentials() } + } + .disabled(accessKeyID.isEmpty || secretAccessKey.isEmpty) + } + } + } + + func saveBedrockCredentials() async throws { + var config = try await fileAPI.readOpenClawConfig() + config.env["AWS_ACCESS_KEY_ID"] = accessKeyID + config.env["AWS_SECRET_ACCESS_KEY"] = secretAccessKey + if !sessionToken.isEmpty { + config.env["AWS_SESSION_TOKEN"] = sessionToken + } + config.env["AWS_REGION"] = region + try await fileAPI.writeOpenClawConfig(config) + try await fileAPI.reloadGateway() + } +} +``` + +Config written to `openclaw.json`: + +```json5 +{ + env: { + AWS_ACCESS_KEY_ID: "AKIA...", + AWS_SECRET_ACCESS_KEY: "...", + AWS_REGION: "us-east-1", + }, + agents: { + defaults: { + model: { primary: "amazon-bedrock/anthropic.claude-opus-4-6-20250514-v1:0" }, + }, + }, +} +``` + +--- + +## Endpoint-based providers + +For providers that run inside (or alongside) the user's container, the iOS app only needs to configure an endpoint URL — no secret is required. + +### Ollama (in-container) + +Ollama can run as a sidecar container alongside the OpenClaw pod. From the container's perspective, it is reachable at `http://127.0.0.1:11434`. + +**UI pattern:** A read-only status screen showing the Ollama endpoint and available models. The user does not configure credentials — models are managed server-side. + +Config written to `openclaw.json`: + +```json5 +{ + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + }, + }, + }, + agents: { + defaults: { + model: { primary: "ollama/llama3.2" }, + }, + }, +} +``` + +If users can choose which Ollama model to use, present the model list (fetched from the file API or a container status endpoint) as a picker in the iOS settings screen. + +### vLLM + +vLLM exposes an OpenAI-compatible API, typically on port 8000. + +```json5 +{ + env: { VLLM_API_KEY: "vllm-local" }, + models: { + providers: { + vllm: { + baseUrl: "http://127.0.0.1:8000/v1", + }, + }, + }, + agents: { + defaults: { + model: { primary: "vllm/meta-llama/Llama-3.1-8B-Instruct" }, + }, + }, +} +``` + +### sglang + +```json5 +{ + env: { SGLANG_API_KEY: "sglang-local" }, + models: { + providers: { + sglang: { + baseUrl: "http://127.0.0.1:30000/v1", + }, + }, + }, +} +``` + +### LiteLLM proxy + +If the managed service runs a shared LiteLLM proxy that routes to multiple backends: + +- **Env key:** `LITELLM_API_KEY` (or the proxy's configured master key) +- **Env key:** `LITELLM_BASE_URL` (the proxy URL) + +```json5 +{ + env: { + LITELLM_API_KEY: "sk-...", + LITELLM_BASE_URL: "https://litellm.your-infra.example.com", + }, +} +``` + +### Cloudflare AI Gateway + +Cloudflare AI Gateway is a managed proxy/cache that sits in front of other providers. In the iOS app, configure the gateway URL and the underlying provider key together. + +- **Dashboard:** `https://dash.cloudflare.com` → AI → AI Gateway +- **Env key:** `CLOUDFLARE_AI_GATEWAY_URL` (the gateway's base URL) +- Plus the underlying provider key (e.g., `OPENAI_API_KEY`) + +```json5 +{ + env: { + CLOUDFLARE_AI_GATEWAY_URL: "https://gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY_NAME", + OPENAI_API_KEY: "sk-...", + }, +} +``` + +--- + +## Provider picker UX + +Combine all of the above into a single **"Add provider"** flow in the iOS app: + +```swift +struct AddProviderView: View { + enum Provider: String, CaseIterable, Identifiable { + case anthropicKey = "Anthropic (API key)" + case anthropicToken = "Anthropic (Claude subscription)" + case openaiKey = "OpenAI (API key)" + case openaiCodex = "OpenAI (Codex / ChatGPT)" + case githubCopilot = "GitHub Copilot" + case qwen = "Qwen" + case bedrock = "Amazon Bedrock" + case mistral = "Mistral" + case togetherAI = "Together AI" + case openrouter = "OpenRouter" + case venice = "Venice AI" + case huggingface = "Hugging Face" + case ollama = "Ollama (in-container)" + case vllm = "vLLM (in-container)" + var id: String { rawValue } + } + + @State private var selectedProvider: Provider? + + var body: some View { + List(Provider.allCases) { provider in + Button(provider.rawValue) { + selectedProvider = provider + } + } + .navigationTitle("Add provider") + .navigationDestination(item: $selectedProvider) { provider in + destinationView(for: provider) + } + } + + @ViewBuilder + func destinationView(for provider: Provider) -> some View { + switch provider { + case .anthropicKey: + APIKeyConnectView( + envKey: "ANTHROPIC_API_KEY", + dashboardURL: URL(string: "https://console.anthropic.com/settings/keys")! + ) + case .anthropicToken: + ClaudeSetupTokenView() + case .openaiKey: + APIKeyConnectView( + envKey: "OPENAI_API_KEY", + dashboardURL: URL(string: "https://platform.openai.com/api-keys")! + ) + case .openaiCodex: + OAuthConnectView(provider: .openaiCodex) + case .githubCopilot: + OAuthConnectView(provider: .githubCopilot) + case .qwen: + DeviceFlowConnectView(provider: .qwen) + case .bedrock: + BedrockConnectView() + case .mistral: + APIKeyConnectView( + envKey: "MISTRAL_API_KEY", + dashboardURL: URL(string: "https://console.mistral.ai/api-keys/")! + ) + case .togetherAI: + APIKeyConnectView( + envKey: "TOGETHER_API_KEY", + dashboardURL: URL(string: "https://api.together.xyz/settings/api-keys")! + ) + case .openrouter: + APIKeyConnectView( + envKey: "OPENROUTER_API_KEY", + dashboardURL: URL(string: "https://openrouter.ai/keys")! + ) + case .venice: + APIKeyConnectView( + envKey: "VENICE_API_KEY", + dashboardURL: URL(string: "https://venice.ai/settings/api")! + ) + case .huggingface: + APIKeyConnectView( + envKey: "HUGGINGFACE_HUB_TOKEN", + dashboardURL: URL(string: "https://huggingface.co/settings/tokens")! + ) + case .ollama: + EndpointStatusView(providerName: "Ollama", endpoint: "http://127.0.0.1:11434") + case .vllm: + EndpointStatusView(providerName: "vLLM", endpoint: "http://127.0.0.1:8000/v1") + } + } +} +``` + +--- + +## Security considerations + +- **Never log or transmit API keys in plain text.** Use HTTPS everywhere — the file API already requires Supabase JWT over TLS. +- **Mask keys in the UI** after they are saved. Show only the last 4 characters (e.g., `sk-ant-...abcd`). +- **Validate key format client-side** before submission (e.g., `sk-` prefix for OpenAI) to catch paste errors early. +- **Scope OAuth tokens** to the minimum required permissions. For GitHub Copilot, `read:user` is enough. +- **Rotate secrets** via the same Settings → Provider screen. Overwriting a key via the file API replaces the old value atomically. +- **AWS credentials**: prefer short-lived STS credentials and a dedicated IAM role with only `bedrock:InvokeModel*` permissions. Do not store root account credentials. + +## See also + +- [OpenAI](/providers/openai) +- [Anthropic](/providers/anthropic) +- [GitHub Copilot](/providers/github-copilot) +- [Qwen](/providers/qwen) +- [Amazon Bedrock](/providers/bedrock) +- [Ollama](/providers/ollama) +- [vLLM](/providers/vllm) +- [Model providers overview](/providers/index) From 859391141c730485acd5ce154ff450d6b3f4a1e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:28:36 +0000 Subject: [PATCH 3/4] docs: move ios-managed-app guide to /dean folder Co-authored-by: deankroker <9809445+deankroker@users.noreply.github.com> --- {docs/install => dean}/ios-managed-app.md | 0 docs/docs.json | 1 - 2 files changed, 1 deletion(-) rename {docs/install => dean}/ios-managed-app.md (100%) diff --git a/docs/install/ios-managed-app.md b/dean/ios-managed-app.md similarity index 100% rename from docs/install/ios-managed-app.md rename to dean/ios-managed-app.md diff --git a/docs/docs.json b/docs/docs.json index 27a3751a66604..b042fbbf900aa 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -894,7 +894,6 @@ "pages": [ "vps", "install/kubernetes", - "install/ios-managed-app", "install/fly", "install/hetzner", "install/gcp", From 89a9506854eeccbf3d7357bd8c8f65f35c33efa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:00:08 +0000 Subject: [PATCH 4/4] docs: add ios-model-switching.md to /dean folder Co-authored-by: deankroker <9809445+deankroker@users.noreply.github.com> --- dean/ios-model-switching.md | 273 ++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 dean/ios-model-switching.md diff --git a/dean/ios-model-switching.md b/dean/ios-model-switching.md new file mode 100644 index 0000000000000..3999446ef9d27 --- /dev/null +++ b/dean/ios-model-switching.md @@ -0,0 +1,273 @@ +--- +summary: "Guide to implementing model switching in a managed OpenClaw iOS app" +read_when: + - You are building an iOS app that manages OpenClaw containers on behalf of users + - You need to implement a model picker or model switching UI in native iOS + - You want to set the primary model, fallbacks, or allowlist from an iOS interface +title: "iOS Managed App — Model Switching" +--- + +# iOS Managed App — Model Switching + +This guide covers how to implement **model switching** in an iOS app that manages dedicated OpenClaw containers. Users select their active model from a native iOS UI; the iOS app writes the change to `openclaw.json` via the file-API bridge (authenticated with the user's Supabase JWT). + +For provider authentication (connecting a provider for the first time), see [`ios-managed-app.md`](./ios-managed-app.md). + +## Architecture recap + +Model configuration lives in `openclaw.json` in the user's mounted Azure Files share. The iOS app reads and writes this file through the file-API pod: + +``` +iOS app → HTTPS + Supabase JWT → files.spark.ooo → openclaw.json → OpenClaw pod reload +``` + +## Model config keys + +| Key | Purpose | +|---|---| +| `agents.defaults.model.primary` | Active model for all new sessions | +| `agents.defaults.model.fallbacks` | Ordered list tried when primary fails | +| `agents.defaults.models` | Allowlist + aliases (empty = all models allowed) | +| `agents.defaults.imageModel.primary` | Model used when primary can't accept images | +| `agents.defaults.imageGenerationModel.primary` | Model used for image generation | + +## Core pattern + +Read → patch primary → write → reload: + +```swift +// Conceptual — adapt to your actual file-api client +func setActiveModel(providerModel: String) async throws { + var config = try await fileAPI.readOpenClawConfig() + config.agents.defaults.model.primary = providerModel + try await fileAPI.writeOpenClawConfig(config) + try await fileAPI.reloadGateway() +} +``` + +## Building the model picker + +### 1. Define your catalog + +Keep the model catalog in the iOS app so the picker works without a round-trip: + +```swift +struct ModelEntry: Identifiable, Hashable { + let id: String // "provider/model-id" — used in config + let displayName: String + let provider: String + let tier: ModelTier +} + +enum ModelTier: String, CaseIterable { + case frontier = "Frontier" + case balanced = "Balanced" + case fast = "Fast" + case local = "Local" +} + +let modelCatalog: [ModelEntry] = [ + // Frontier + ModelEntry(id: "anthropic/claude-opus-4-6", displayName: "Claude Opus 4.6", provider: "Anthropic", tier: .frontier), + ModelEntry(id: "openai/gpt-5.4", displayName: "GPT-5.4", provider: "OpenAI", tier: .frontier), + ModelEntry(id: "openai/o3", displayName: "o3", provider: "OpenAI", tier: .frontier), + ModelEntry(id: "openai-codex/gpt-5.4", displayName: "Codex GPT-5.4", provider: "OpenAI Codex",tier: .frontier), + ModelEntry(id: "github-copilot/gpt-4.1", displayName: "Copilot GPT-4.1", provider: "GitHub Copilot", tier: .frontier), + + // Balanced + ModelEntry(id: "anthropic/claude-sonnet-4-5", displayName: "Claude Sonnet 4.5", provider: "Anthropic", tier: .balanced), + ModelEntry(id: "openai/gpt-4.1", displayName: "GPT-4.1", provider: "OpenAI", tier: .balanced), + ModelEntry(id: "mistral/mistral-large-latest", displayName: "Mistral Large", provider: "Mistral", tier: .balanced), + ModelEntry(id: "together/moonshotai/Kimi-K2.5", displayName: "Kimi K2.5", provider: "Together AI", tier: .balanced), + ModelEntry(id: "openrouter/anthropic/claude-sonnet-4-5", displayName: "Claude Sonnet (OpenRouter)", provider: "OpenRouter", tier: .balanced), + + // Fast + ModelEntry(id: "anthropic/claude-haiku-4-5", displayName: "Claude Haiku 4.5", provider: "Anthropic", tier: .fast), + ModelEntry(id: "openai/gpt-5-mini", displayName: "GPT-5 mini", provider: "OpenAI", tier: .fast), + ModelEntry(id: "mistral/mistral-small-latest", displayName: "Mistral Small", provider: "Mistral", tier: .fast), + ModelEntry(id: "moonshot/moonshot-v1-8k", displayName: "Moonshot v1-8k", provider: "Moonshot", tier: .fast), + + // Local + ModelEntry(id: "ollama/llama3.2", displayName: "Llama 3.2 (Ollama)", provider: "Ollama", tier: .local), + ModelEntry(id: "ollama/qwen2.5-coder", displayName: "Qwen 2.5 Coder", provider: "Ollama", tier: .local), +] +``` + +### 2. Model picker view + +```swift +struct ModelPickerView: View { + @State private var currentModel: String = "" + @State private var isLoading = false + @State private var errorMessage: String? + let fileAPI: FileAPIClient + + var groupedModels: [(ModelTier, [ModelEntry])] { + ModelTier.allCases.compactMap { tier in + let entries = modelCatalog.filter { $0.tier == tier } + return entries.isEmpty ? nil : (tier, entries) + } + } + + var body: some View { + NavigationStack { + List { + ForEach(groupedModels, id: \.0) { tier, entries in + Section(tier.rawValue) { + ForEach(entries) { entry in + ModelRow( + entry: entry, + isSelected: currentModel == entry.id, + onSelect: { selectModel(entry) } + ) + } + } + } + } + .navigationTitle("Active Model") + .overlay { + if isLoading { ProgressView() } + } + .alert("Error", isPresented: .constant(errorMessage != nil)) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + } + .task { await loadCurrentModel() } + } + + private func loadCurrentModel() async { + do { + let config = try await fileAPI.readOpenClawConfig() + currentModel = config.agents?.defaults?.model?.primary ?? "" + } catch { + errorMessage = error.localizedDescription + } + } + + private func selectModel(_ entry: ModelEntry) { + isLoading = true + Task { + do { + try await setActiveModel(providerModel: entry.id) + currentModel = entry.id + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } +} + +struct ModelRow: View { + let entry: ModelEntry + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(entry.displayName) + .foregroundStyle(.primary) + Text(entry.provider) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + } + } + } + .contentShape(Rectangle()) + } +} +``` + +### 3. Setting fallbacks + +For resilience, let users configure an ordered fallback list: + +```swift +func setFallbacks(models: [String]) async throws { + var config = try await fileAPI.readOpenClawConfig() + config.agents.defaults.model.fallbacks = models + try await fileAPI.writeOpenClawConfig(config) + try await fileAPI.reloadGateway() +} + +// Example: set Sonnet as fallback behind Opus +try await setFallbacks(models: [ + "anthropic/claude-sonnet-4-5", + "openai/gpt-4.1", +]) +``` + +### 4. Restricting the allowlist + +To limit which models users can pick in-session (via `/model` in chat), set `agents.defaults.models`: + +```swift +struct ModelAllowEntry: Codable { + var alias: String? +} + +func setAllowlist(_ entries: [String: ModelAllowEntry]) async throws { + var config = try await fileAPI.readOpenClawConfig() + config.agents.defaults.models = entries + try await fileAPI.writeOpenClawConfig(config) + try await fileAPI.reloadGateway() +} + +// Example: limit to Opus and Sonnet with friendly aliases +try await setAllowlist([ + "anthropic/claude-opus-4-6": ModelAllowEntry(alias: "Opus"), + "anthropic/claude-sonnet-4-5": ModelAllowEntry(alias: "Sonnet"), +]) +``` + +> **Note:** An empty or absent `agents.defaults.models` allows all models. Setting it to a non-empty map restricts in-session `/model` to only those entries. + +### 5. Image model + +If your app exposes image-processing workflows, you can let users set the image model separately: + +```swift +func setImageModel(providerModel: String) async throws { + var config = try await fileAPI.readOpenClawConfig() + config.agents.defaults.imageModel.primary = providerModel + try await fileAPI.writeOpenClawConfig(config) + try await fileAPI.reloadGateway() +} +``` + +Typical image-capable models: `openai/gpt-4o`, `anthropic/claude-opus-4-6`, `openai/gpt-5.4`. + +## Filtering the picker by connected providers + +Show only models whose provider the user has already connected: + +```swift +func availableModels(connectedProviders: Set) -> [ModelEntry] { + modelCatalog.filter { connectedProviders.contains($0.provider.lowercased()) } +} +``` + +Call `availableModels` with the provider IDs read from the user's `openclaw.json` env block (the same keys set by the quick-connect flows in `ios-managed-app.md`). + +## Model ref format + +Model refs follow the `provider/model-id` format. A few edge cases: + +- **OpenRouter models** include a second `/`: use the full `openrouter/org/model-id` form (e.g., `openrouter/anthropic/claude-sonnet-4-5`). +- **Provider aliases** (e.g., `z.ai/*`) normalize to their canonical form (`zai/*`) internally; use the canonical form in config. +- Model refs are case-insensitive; OpenClaw normalizes to lowercase. + +## Security notes + +- Model switching is a configuration change, not a credential operation — no additional key vaulting is required. +- Restrict the allowlist in managed deployments to avoid users switching to expensive frontier models unexpectedly. +- The file-API bridge validates the Supabase JWT on every write; unauthenticated model-switch attempts are rejected at the file-api layer.