OpenCAN is an iOS client for the Agent Client Protocol (ACP) that drives remote coding agents through a persistent daemon over SSH.
It is built for the annoying real-world case where you are still in the app, but the network is not. The SSH connection can drop, the remote daemon can keep the conversation alive, and the phone can later reopen the same chat instead of starting over.
The name OpenCAN comes from "You CAN just build". If you prefer, you can also read it as "open can" as in opening a can. Both readings are fine.
- Many remote coding-agent products already exist, but this space often asks high-privilege agents to run behind convenience-first connectivity layers. OpenCAN takes a security-first position instead.
- Keep remote coding sessions usable on unstable mobile networks.
- Treat conversations as durable identities instead of tying UX to a fragile live runtime.
- Reopen daemon-owned conversations from the phone, including sessions adopted from elsewhere.
- Open the can from the original, correct entry point: SSH. No inbound tunnels, no third-party long-lived relay, and no need to punch through or weaken your server's existing firewall posture.
- Preserve enough diagnostics on both client and server to debug reconnect and delivery problems.
- Persistent remote
opencan-daemonthat decouples agent/runtime lifetime from mobile SSH lifetime - Conversation-oriented reopen and restore flow for sessions started on phone or adopted later from another machine
- Full ACP over JSON-RPC 2.0 using SSH PTY stdio
- Streaming chat, tool call rendering, image mention uploads, and Markdown rendering
- Structured diagnostics in the iOS app (
opencan.log) and on the remote host (~/.opencan/daemon.log) - Local SSH-backed integration harness for deterministic end-to-end testing on macOS
- The iPhone connects to a remote host over SSH.
- Instead of launching ACP agents directly from the app, OpenCAN talks to
opencan-daemon. - The daemon owns conversation lifecycle, buffering, replay, restore, and diagnostics.
- When the mobile transport drops, the daemon can keep the conversation alive and the app can later reopen it.
- iOS 17.0+
- Xcode 16+
- XcodeGen (
brew install xcodegen) - A reachable remote host with an ACP launcher command such as
claude-agent-acporcodex-acp
# Generate the Xcode project from project.yml
xcodegen generate
# Build for iOS Simulator
xcodebuild -scheme OpenCAN \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-quiet buildInstall and launch on a simulator:
SIM=<your-simulator-udid>
APP=$(find ~/Library/Developer/Xcode/DerivedData/OpenCAN-*/Build/Products/Debug-iphonesimulator \
-name "OpenCAN.app" -maxdepth 1 | head -1)
xcrun simctl install "$SIM" "$APP"
xcrun simctl terminate "$SIM" com.tianyizhuang.OpenCAN 2>/dev/null
xcrun simctl launch "$SIM" com.tianyizhuang.OpenCANAfter launch, add a node, configure SSH credentials, and point the app at a remote host that can launch an ACP server.
App logs are written to the app sandbox rather than the simulator system log:
CONTAINER=$(xcrun simctl get_app_container "$SIM" com.tianyizhuang.OpenCAN data)
cat "$CONTAINER/Documents/opencan.log"The app rotates opencan.log by size and retains opencan.log.1 through opencan.log.3.
Copy logs from a real device:
DEVICE=<device-udid>
xcrun devicectl device copy from --device "$DEVICE" \
--source Documents/opencan.log --destination /tmp/opencan.log \
--domain-type appDataContainer --domain-identifier com.tianyizhuang.OpenCANRemote daemon logs live at ~/.opencan/daemon.log and rotate by size while the daemon is running, retaining daemon.log.1 through daemon.log.3.
The in-app Diagnostics screen can also generate a shareable JSON diagnostics bundle that captures current app log files, recent daemon log files fetched over SSH, ring-buffer snapshots, and the active app state in one file.
Run the unit test target:
xcodebuild test -scheme OpenCAN \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing:OpenCANTestsRun the end-to-end SSH + daemon + mock ACP smoke suite locally:
OPENCAN_INTEGRATION_TEST_MODE=smoke ./Scripts/run-local-integration.shRun the full integration target:
OPENCAN_INTEGRATION_TEST_MODE=full ./Scripts/run-local-integration.shFor more detail, see docs/local-integration-testing.md and docs/testing-strategy.md.
OpenCAN uses a daemon-owned conversation/runtime model.
| Layer | Key Types | Role |
|---|---|---|
| SSH | SSHConnectionManager, SSHStdioTransport |
RSA key auth, optional jump host, PTY channel, daemon auto-deploy |
| JSON-RPC | JSONRPCFramer, JSONRPCMessage, JSONValue |
Newline-delimited JSON-RPC 2.0 framing |
| Daemon | opencan-daemon, ClientHandler, SessionManager, ACPProxy |
Conversation registry, runtime lifecycle, replay, restore, diagnostics |
| ACP | ACPClient, DaemonClient, ACPService, SessionUpdateParser |
Request correlation, daemon RPCs, ACP passthrough, notification parsing |
| Conversation | ConversationLifecycle, ConversationPersistence, PromptLifecycle |
Conversation open/recover flows, local session sync, prompt terminal-state handling |
| AppState | AppState, ChatMessage |
UI-facing coordinator, connection state, transcript state, and navigation context |
| SwiftUI | ContentView, SessionPickerView, ChatView, DiagnosticView |
Navigation, picker, chat UX, diagnostics UI |
conversationId: stable identity persisted by the app and used to reopen chat historyruntimeId: ephemeral daemon-managed live runtime identity used for attachment and replayownerId: stable per-install client identity used for same-owner reclaim after reconnect
- iOS connects to
opencan-daemon attach, not directly to ACP launchers daemon/conversation.create|open|detach|listare the product-facing lifecycle APIsdaemon/session.list|killremain low-level diagnostic and operational APIs- Forwarded
session/updatenotifications include__seq,conversationId, andruntimeId - On restored conversations, daemon routes by
runtimeIdinternally but forwards upstream ACP requests with the stable wiresessionId = conversationId; usingruntimeIdon the ACP wire loses history context - Prompt termination must be observable via
prompt_complete, prompt error, or prompt success fallback - App-side follow-up sends stay serial with ACP turns: if a conversation is reopened in
starting,prompting, ordraining, the app keeps the session busy and queues new user sends on the active conversation until the turn settles
For the current implemented contract, see docs/daemon-architecture.md and CLAUDE.md. docs/conversation-runtime-refactor.md remains useful as design background, not the canonical source for current behavior.
Sources/
├── AppState.swift # Main app coordinator and transcript state
├── ACP/ # ACP client, daemon client, prompt helpers
├── JSONRPC/ # JSON-RPC framing and generic JSON values
├── Models/ # SwiftData models and daemon DTOs
├── Services/ # SSH connection management and deployment
├── Transport/ # SSH PTY transport
├── Views/ # SwiftUI/UIKit hybrid UI
└── Utils/ # Structured logging and utilities
opencan-daemon/
├── cmd/ # Daemon entrypoint
├── internal/ # Conversation registry, protocol router, ACP proxy
└── test/ # Daemon integration / contract tests
Scripts/
├── run-local-integration.sh # Local SSH-backed integration harness
└── setup-local-ssh.sh # Local sshd setup for integration tests
Contributions are welcome, but please keep the repo's contract boundaries and docs in sync.
- Run
xcodegen generateafter adding or removing files tracked byproject.yml. - Run
xcodebuild test -scheme OpenCAN -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:OpenCANTestsbefore opening a PR. - If you change daemon or conversation lifecycle behavior, update
README.md,CLAUDE.md, and docs/daemon-architecture.md in the same patch. - Prefer adding regression tests for prompt termination, replay ordering, reopen semantics, restore behavior, and cross-session filtering.
- Citadel for SSH
- MarkdownView for Markdown rendering
- ListViewKit for stable streaming chat timeline rendering
- OpenCAN's chat timeline UI direction was informed by FlowDown by Lakr233.
- OpenCAN uses ListViewKit and MarkdownView, which are maintained by Lakr233 and are used directly in the app.
- FlowDown's repository currently states that its source code is AGPL-3.0, while the FlowDown name, icon, and artwork remain proprietary. OpenCAN does not claim rights to those brand assets.
- See THIRD_PARTY_NOTICES.md for dependency license texts and attribution notes.
Copyright (C) 2026 TennyZhuang.
OpenCAN is released under the GNU Affero General Public License v3.0 or later. See LICENSE.
If you distribute binaries of OpenCAN or make a modified networked deployment of opencan-daemon available to users, you are responsible for providing the corresponding source and keeping the required legal notices visible.
See docs/open-source-release.md for the current provenance audit, source-availability policy, and release checklist.