- Shell 59.3%
- HTML 20.8%
- TypeScript 13.7%
- Smarty 3.9%
- Dockerfile 2.3%
| .forgejo/workflows | ||
| bin | ||
| chart | ||
| config | ||
| scripts | ||
| systemd | ||
| webui | ||
| README.md | ||
cowork-canvas
A sync system that keeps Claude session state (Cowork, Code, Dispatch) consistent across multiple devices, using a Kubernetes-hosted server as the source of truth.
Architecture
┌──────────┐ ┌──────────┐ ┌──────────────────────────┐
│ Laptop │ │ Desktop │ │ Kubernetes (Talos) │
│ │ │ │ │ ┌────────────────────┐ │
│ ~/.claude│ │ ~/.claude│ │ │ cowork-canvas pod │ │
│ sessions │ │ sessions │ │ │ sshd + git + rsync│ │
│ │ │ │ │ │ /data/canvas (PVC)│ │
└────┬─────┘ └────┬─────┘ │ └────────────────────┘ │
│ │ │ LoadBalancer :22 │
└──── push/pull ─┴───────────┴──────────────────────────┘
(every 5 min via systemd timer)
Two sync layers work together:
Git layer — syncs ~/.claude/ (config, history, commands, skills, todos, project metadata). The container hosts a bare git repo at /data/canvas/claude-config.git. Each client auto-commits and pushes/pulls on every sync cycle.
Rsync layer — syncs ~/.config/Claude/local-agent-mode-sessions/ (session audit logs, spaces, plugin state) and other large/binary data. Uses exclusion lists to skip browser caches, credentials, and device-specific state.
What gets synced
| Data | Method | Location |
|---|---|---|
| Settings, history, commands | git | ~/.claude/ |
| Custom skills & plugins config | git | ~/.claude/skills/, settings.json |
| Todos & project metadata | git | ~/.claude/todos/, projects/ |
| Session audit logs | rsync | ~/.config/Claude/local-agent-mode-sessions/ |
| Spaces & workspace state | rsync | ~/.config/Claude/local-agent-mode-sessions/ |
| Desktop config | rsync | ~/.config/Claude/claude_desktop_config.json |
| Claude Code state | rsync | ~/.config/Claude/claude-code/ |
Excluded: browser caches, credentials/tokens, GPU caches, IndexedDB, VM state, lock files.
Server deployment (Kubernetes)
The server runs as a single pod with an SSH server, backed by a PVC for persistent storage. Deploy via ArgoCD or any GitOps tool.
Helm chart
chart/cowork-canvas/
├── Chart.yaml
├── values.yaml
├── templates/
│ ├── _helpers.tpl
│ ├── configmap.yaml # sshd config, init/entrypoint/backup scripts
│ ├── deployment.yaml # SSH server pod
│ ├── service.yaml # LoadBalancer or NodePort
│ ├── pvc.yaml # data + optional backup volumes
│ ├── cronjob.yaml # daily backup tar
│ └── argocd-application.yaml
└── Dockerfile # optional custom image build
Minimal values override
ssh:
authorizedKeys: |
ssh-ed25519 AAAA... laptop
ssh-ed25519 AAAA... desktop
ssh-ed25519 AAAA... workstation
persistence:
data:
storageClass: "your-drbd-class"
size: 5Gi
service:
type: LoadBalancer
annotations:
metallb.universe.tf/loadBalancerIPs: "10.0.0.50"
Using sealed secrets for SSH keys
Instead of putting authorized keys in values (which end up in a ConfigMap), reference an existing Secret:
ssh:
existingAuthorizedKeysSecret: "cowork-canvas-ssh-keys"
existingHostKeysSecret: "cowork-canvas-host-keys"
ArgoCD Application
The chart includes an optional ArgoCD Application template. For an app-of-apps pattern, set argocd.enabled: false and create the Application in your gitops repo:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cowork-canvas
namespace: argocd
spec:
project: default
source:
repoURL: https://git.your-domain.com/your-org/cowork-canvas.git
targetRevision: main
path: chart/cowork-canvas
helm:
valueFiles:
- values.yaml
- values-production.yaml
destination:
server: https://kubernetes.default.svc
namespace: cowork-canvas
syncPolicy:
automated:
prune: true
selfHeal: true
What the init container does
On first boot (or whenever the PVC is empty), the init container:
- Creates the directory structure (
sessions/,desktop-config/,claude-code/, etc.) - Initializes a bare git repo at
/data/canvas/claude-config.git - Generates SSH host keys (persisted to PVC so they survive pod restarts)
If you provide host keys via a Secret (ssh.existingHostKeysSecret), the generated keys are ignored.
Client setup (each device)
Quick start
git clone <this-repo> ~/.local/share/cowork-canvas/repo
~/.local/share/cowork-canvas/repo/scripts/client-bootstrap.sh
The bootstrap script will:
- Symlink
canvas-syncinto~/.local/bin - Create
~/.config/cowork-canvas/canvas.conf - Walk you through server connection details
- Optionally install + enable the systemd timer
Manual usage
canvas-sync status # show sync state & connectivity
canvas-sync server-check # verify the k8s server is ready
canvas-sync sync # full pull + push
canvas-sync push # push local → server
canvas-sync pull # pull server → local
canvas-sync bootstrap # clone all server state to a new device
canvas-sync log # tail the sync log
Configuration
Config lives at ~/.config/cowork-canvas/canvas.conf:
CANVAS_SERVER="canvas@10.0.0.50" # SSH target (LoadBalancer IP)
CANVAS_PORT="22"
CANVAS_SERVER_PATH="/data/canvas" # path inside the container
CANVAS_DEVICE_NAME="" # auto from hostname
CANVAS_SYNC_INTERVAL="300" # 5 minutes
CANVAS_SYNC_MODE="auto" # auto | pull-only | push-only
CANVAS_CONFLICT_STRATEGY="server" # server | local | newest
Systemd integration
The timer syncs every 5 minutes with a 30-second jitter to avoid thundering herd across devices:
systemctl --user enable --now canvas-sync.timer
systemctl --user list-timers canvas-sync.timer
journalctl --user -u canvas-sync.service -f
Conflict resolution
Git conflicts use the configured strategy (default: server wins). Rsync uses --delete-after so the last push wins for session data. The state directory records which device last pushed for auditability.
File structure
cowork-canvas/
├── bin/
│ └── canvas-sync # main CLI tool
├── chart/
│ ├── Dockerfile # custom SSH server image (optional)
│ └── cowork-canvas/ # Helm chart
│ ├── Chart.yaml
│ ├── values.yaml
│ └── templates/
├── config/
│ ├── canvas.conf.example # client config template
│ ├── rsync-exclude.conf # rsync exclusion rules
│ └── git-exclude.conf # git exclusion rules
├── scripts/
│ └── client-bootstrap.sh # per-device setup
├── systemd/
│ ├── canvas-sync.service # oneshot sync unit
│ ├── canvas-sync.timer # 5-min periodic timer
│ └── canvas-sync-watch.service # daemon mode (alternative)
└── README.md