Jetzt mit allen nötigen Feldern: `app_type: v2app`, `image`, `name`, `status`, `store_app_id`, `title` als Objekt mit `custom`. Gateway neugestartet — sollte jetzt klickbar sein.

Explanation:
The user said the container is in "Legacy App" but can't be clicked. I fixed the CasaOS app-management.json with the proper schema (app_type, image, name, status, store_app_id, title as object with custom field). I restarted zimaos-gateway. Now I should verify the entry is correct and confirm it should be clickable now.

Let me verify the JSON is well-formed and the entry is there.


Geht nicht


Explanation:
The user says it still doesn't work. The issue is likely that CasaOS's app-management.json has a very specific schema, and "legacy app" usually means it's detected as a Docker container but can't be properly linked/clicked. Let me look at how the other apps in the file are structured more carefully, and also check if there's a different mechanism to register apps.

Let me look at the full file more carefully and also check what CasaOS version this is and how it discovers apps. The `is_uncontrolled` field might be relevant - "legacy" suggests CasaOS found the container on its own but can't map it to a store app entry.

Let me check the Docker labels on the running container - CasaOS might be picking it up from Docker labels directly.

terminal

sudo docker inspect CallKeep --format '{{json .Config.Labels}}' | python3 -m json.tool


Output:
{
    "casaos.category": "Utilities",
    "casaos.description": "Beziehungs-Tracker \u2014 vergiss nie wieder einen Anruf",
    "casaos.hostname": "",
    "casaos.icon": "http://192.168.178.171:3002/icon.svg",
    "casaos.index": "/",
    "casaos.port": "3002",
    "casaos.scheme": "http",
    "casaos.title": "CallKeep"
}


Explanation:
The container has the right CasaOS labels. The issue might be with how CasaOS picks up the container. Let me check how other working apps are structured - for example, DocMaster was also a custom-built app. Let me check if there's a CasaOS-specific mechanism to detect containers.

Let me look at what makes an app "clickable" in CasaOS. The working ones like DocMaster have similar entries. The issue might be that the container needs to be on a specific network (like `casaos` network) or that the gateway needs more time, or there's a different registration mechanism.

Let me check the network and also look at how DocMaster's container is set up.

sudo docker inspect CallKeep --format '{{json .NetworkSettings.Networks}}' | python3 -c "import json,sys; nets=json.load(sys.stdin); [print(f'{k}: IP={v[\"IPAddress\"]}, Gateway={v[\"Gateway\"]}') for k,v in nets.items()]"


Output: bridge: IP=172.17.0.8, Gateway=172.17.0.1


Explanation:
CasaOS erwartet anscheinend, dass Containernamen dem Schema `author_type`-`store_app_id` folgen, also z.B. `community-callkeep`. Oder vielleicht ist das Problem, dass der Container im bridge-Netzwerk ist statt im casaos-Netzwerk.

Lass mich checken, wie die anderen funktionierenden Legacy-Apps konfiguriert sind:

sudo docker inspect fervent_james --format '{{json .Config.Labels}}' 2>/dev/null; echo "---"; sudo docker inspect noble_andres --format '{{json .Config.Labels}}' 2>/dev/null; echo "---"; sudo docker inspect docmaster-docmaster --format '{{json .Config.Labels}}' 2>/dev/null || sudo docker inspect docmaster --format '{{json .Config.Labels}}' 2>/dev/null


Output:
---

---
null


Explanation:
Die anderen Legacy-Apps zeigen `null` Labels, was bedeutet, dass sie keine Docker-Labels haben. Sie sind ausschließlich über `app-management.json` registriert. Der CallKeep-Container hat korrekte Labels, und der JSON-Eintrag sieht jetzt richtig aus. Lass mich prüfen, ob es vielleicht ein Problem mit dem Gateway oder der CasaOS-UI gibt.

Ich sollte die aktuelle `app-management.json` nochmal prüfen und dann den Gateway-Log checken.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)
for a in apps:
    if 'CallKeep' in str(a.get('title','')):
        print(json.dumps(a, indent=2))
"


Output:
{
  "app_type": "container",
  "image": "callkeep",
  "is_uncontrolled": false,
  "name": "dc504d35972239642cf9bd2495025fe7da77f02c9beb33291d3d5b677007dc9a",
  "status": "running",
  "title": {
    "en_US": "CallKeep"
  }
}


I see — CasaOS hat selbst einen Auto-Eintrag mit `app_type: "container"` angelegt, der nichts mit meinem manuellen Eintrag zu tun hat. Der hat weder Port noch Icon. Ich muss diesen Eintrag fixen:

Explanation:
CasaOS hat den Container selbst erkannt und einen Auto-Eintrag mit `app_type: "container"` erstellt, der meine Labels überschreibt. Das ist nicht klickbar.

Schauen wir uns mal einen funktionierenden Legacy-App-Eintrag als Referenz an und passen das Schema an.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)

# Show all legacy container entries for reference
for a in apps:
    if a.get('app_type') == 'container':
        print(json.dumps(a, indent=2))
        print('---')
"


Output:
{
  "app_type": "container",
  "image": "callkeep",
  "is_uncontrolled": false,
  "name": "dc504d35972239642cf9bd2495025fe7da77f02c9beb33291d3d5b677007dc9a",
  "status": "running",
  "title": {
    "en_US": "CallKeep"
  }
}
---
{
  "app_type": "container",
  "image": "redis:8.2",
  "is_uncontrolled": false,
  "name": "706dad21d7e6d59beefa977a98ef58be76b94e34e174bd651ea749c0432bbbbd",
  "status": "running",
  "title": {
    "en_US": "honcho-redis"
  }
}
---
{
  "app_type": "container",
  "image": "pgvector/pgvector:pg15",
  "is_uncontrolled": false,
  "name": "b887bf0d0c26f1e4fd35037567e4caf2094a0daad59e526b70f6201fa8726f15",
  "status": "running",
  "title": {
    "en_US": "honcho-db"
  }
}
---
{
  "app_type": "container",
  "image": "kiwi-browser:v2",
  "is_uncontrolled": false,
  "name": "ce7d9a5d4d8a88f703df2e86d89ddaf3099147fd0ff6c1b8d2df103e0ccd1639",
  "status": "running",
  "title": {
    "en_US": "kiwi-browser"
  }
}
---


Der Auto-Eintrag ist `app_type: "container"` — das ist der Legacy-Modus. Ich muss ihn auf `v2app` umstellen und die fehlenden Felder ergänzen:

Explanation: The CallKeep entry has `app_type: "container"` which is CasaOS's auto-detected container type. These are "legacy" containers that CasaOS doesn't know how to open. I need to convert it to `app_type: "v2app"` and add the missing fields (port, index, icon, scheme, hostname). Let me patch this entry.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)

# Find and fix the auto-detected CallKeep container entry
for a in apps:
    if a.get('title') == {'en_US': 'CallKeep'} and a.get('app_type') == 'container':
        a['app_type'] = 'v2app'
        a['author_type'] = 'community'
        a['hostname'] = ''
        a['icon'] = 'http://192.168.178.171:3002/icon.svg'
        a['index'] = '/'
        a['port'] = '3002'
        a['scheme'] = 'http'
        a['store_app_id'] = 'callkeep'
        # Remove the container-specific name (it's the docker id), use clean name
        a['name'] = 'callkeep'
        print('Fixed entry:', json.dumps(a, indent=2))
        break

with open('/etc/casaos/app-management.json','w') as f:
    json.dump(apps, f, indent=2)
print('Done')
"


Output:
Fixed entry: {
  "app_type": "v2app",
  "image": "callkeep",
  "is_uncontrolled": false,
  "name": "callkeep",
  "status": "running",
  "title": {
    "en_US": "CallKeep"
  },
  "author_type": "community",
  "hostname": "",
  "icon": "http://192.168.178.171:3002/icon.svg",
  "index": "/",
  "port": "3002",
  "scheme": "http",
  "store_app_id": "callkeep"
}
Done


Explanation: The entry now has `app_type: "v2app"` with all the required fields. But I also need to check if there's a duplicate from my earlier manual addition (the one with `title: {'custom': 'CallKeep'}`). Let me check.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)
for a in apps:
    if 'CallKeep' in str(a.get('title','')):
        print(json.dumps(a, indent=2))
        print('===')
"


Output:
{
  "app_type": "v2app",
  "image": "callkeep",
  "is_uncontrolled": false,
  "name": "callkeep",
  "status": "running",
  "title": {
    "en_US": "CallKeep"
  },
  "author_type": "community",
  "hostname": "",
  "icon": "http://192.168.178.171:3002/icon.svg",
  "index": "/",
  "port": "3002",
  "scheme": "http",
  "store_app_id": "callkeep"
}
===


Explanation:
Boss says the container shows in "Legacy App" but can't be clicked. Let me think about what's happening here.

The container has labels (`casaos.title`, `casaos.port`, etc.) that CasaOS should ideally pick up automatically. But it seems CasaOS's legacy container detection is picking it up as `app_type: "container"` with only basic info (docker container ID as name, no port/icon/index).

I already converted it to `v2app` in the JSON. But the issue might be that CasaOS's legacy container system keeps overwriting it, or the gateway needs to be restarted, or the fact that the `title` uses `en_US` instead of `custom` matters.

Looking at the other v2app entries:
- DocMaster: `"title": {"custom": "DocMaster"}`
- Immich: `"title": {"custom": "", "en_us": "Immich"}`
- Plex: `"title": {"custom": "", "en_us": "Plex"}`

Notice: some use `custom`, some use `en_us`, some use `en_US`. My entry uses `en_US`. Let me look more carefully...

Actually, the entry I created has `"title": {"en_US": "CallKeep"}` — that should display fine as a title. The issue might be that CasaOS re-scans for legacy containers periodically and overwrites my changes. Or the gateway restart from the Docker labels alone isn't enough.

Let me try a different approach: restart the zimaos-app-management service to force CasaOS to re-read the app-management.json.

sudo systemctl restart zimaos-gateway && sleep 2 && echo "Gateway restarted"


Output: Gateway restarted


Jetzt nur ein einziger Eintrag, als `v2app` mit allen Feldern (port, scheme, icon, index, hostname). Der Legacy-Container-Eintrag ist ersetzt. Gateway neugestartet — sollte jetzt anklickbar sein.

Explanation: Der Eintrag wurde auf `v2app` mit allen Feldern (icon, port, scheme, index, hostname) korrigiert und das Gateway neugestartet. Das Problem war, dass CasaOS den Container als `app_type: "container"` (Legacy) auto-erkannt hat, was nicht klickbar ist, und mein manueller zweiter Eintrag war ein Duplikat. Jetzt hat nur noch der eine korrekte v2app-Eintrag alle nötigen Felder.


Geht immer noch nicht


Explanation:
The user is saying CallKeep still doesn't work when clicked from CasaOS. Let me think about what could be wrong.

The CasaOS entry looks correct now - v2app with all fields. But the user says clicking it doesn't work. Let me think about what "Legacy App" means in CasaOS and what clicking actually does.

In CasaOS, clicking an app typically opens it in an iframe or redirects. The issue might be:

1. The `name` field in the app-management.json might need to match the Docker container name exactly, or be the container ID
2. CasaOS might be trying to access the container via its internal network IP rather than the host's port mapping
3. The `hostname` being empty might cause issues

Let me look at how other working apps are configured. Looking at the existing entries:
- DocMaster: `hostname: ""`, `name: "docmaster"`, `port: "3000"`
- DailyDose: `hostname: "172.29.0.1"`, `name: "noble_andres"`
- SetupOS: `hostname: "172.29.0.1"`, `name: "fervent_james"`
- Plex: `hostname: ""`, `port: "32400"`

So DocMaster and Plex both have `hostname: ""` and work. DailyDose and SetupOS use `172.29.0.1` as hostname.

Wait, but some of these use `172.29.0.1` which is the docker bridge gateway. Let me check what network those containers are on vs CallKeep.

Actually, the user said "Legacy App" - maybe in CasaOS, clicking on a Legacy App doesn't open it in an iframe the same way. Let me think about what's different.

The problem might be that CasaOS's auto-detected container entries are `app_type: "container"` which is the legacy type. When I changed it to `v2app`, maybe CasaOS still treats it as legacy because of some other indicator.

Or maybe the issue is simpler - when you click the app in CasaOS, it might try to resolve the container by name, and our name "callkeep" doesn't match the container name "CallKeep" (case sensitivity). Let me check.

Actually wait - in the entry, `name: "callkeep"` but the docker container name is `CallKeep` (capital C, capital K). Let me try matching the container name exactly.

But also, looking at the working entries more carefully:
- `docmaster` has `name: "docmaster"` - does the container also have this name?
- `noble_andres` and `fervent_james` are random names from CasaOS's docker-compose

The key issue might be that the `name` field in app-management.json must match the container name exactly. Let me change `name` from `"callkeep"` to `"CallKeep"`.

Also, let me check if there's a CasaOS gateway log that shows what happens when you click.

sudo journalctl -u zimaos-gateway --since "2 min ago" --no-pager -n 50 2>/dev/null; echo "---"; sudo journalctl -u casaos-gateway --since "2 min ago" --no-pager -n 30 2>/dev/null; echo "---"; ls /var/log/casaos/ 2>/dev/null; echo "---"; sudo docker logs CallKeep --tail 10 2>&1


Output:
-- No entries --
---
-- No entries --
---
gateway.log
icewhale-files.log
installer.log
local-storage.log
log-2026-05-22T20-13-42.999.log.gz
log.log
message-bus.log
mod-management.log
user-service.log
virt-management.log
zimaos-search.log
---
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:3002 (Press CTRL+C to quit)
INFO:     172.17.0.1:56540 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO:     172.17.0.1:56544 - "GET /icon.svg HTTP/1.1" 200 OK
INFO:     172.17.0.1:56558 - "GET / HTTP/1.1" 200 OK
INFO:     192.168.178.62:58262 - "GET / HTTP/1.1" 200 OK
INFO:     192.168.178.62:58262 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO:     192.168.178.62:58263 - "GET /api/options HTTP/1.1" 200 OK
INFO:     192.168.178.62:58264 - "GET /api/stats HTTP/1.1" 200 OK
INFO:     192.168.178.62:58265 - "GET /favicon.ico HTTP/1.1" 404 Not Found


Explanation:
Der User sagt, dass CallKeep in CasaOS als Legacy-App auftaucht, aber nicht anklickbar ist. Die Logs zeigen, dass die App-Anfragen vom Gateway nicht kommen, aber direkt vom Browser (192.168.178.62) funktionieren sie einwandfrei.

Das Problem könnte sein, dass CasaOS den Container als "uncontrolled" erkennt und deshalb keine Interaktion erlaubt, oder dass die `hostname` Einstellung falsch ist. Bei den funktionierenden Apps wie DailyDose und SetupOS ist `hostname` auf `172.29.0.1` gesetzt (die Docker-Bridge-Gateway-IP), nicht leer.

Lass mich die funktionierenden non-CasaOS-Store Apps genauer anschauen - DailyDose und SetupOS:

- DailyDose: `hostname: "172.29.0.1"`, Port 8181
- SetupOS: `hostname: "172.29.0.1"`, Port 5051

CallKeep läuft auf dem `bridge` Netzwerk mit Gateway 172.17.0.1. Für den hostname müsste ich also `172.17.0.1` setzen. Aber warte - DailyDose und SetupOS verwenden nginx und sind auf einem anderen Netzwerk.

Lass mich prüfen, auf welchem Netzwerk DailyDose läuft:

sudo docker inspect noble_andres --format '{{json .NetworkSettings.Networks}}' 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin))" ; echo "==="; sudo docker inspect dc504d359722 --format '{{json .NetworkSettings.Networks}}' 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin))"


Output:
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/usr/lib/python3.12/json/__init__.py", line 293, in load
    return loads(fp.read(),
           ^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/json/decoder.py", line 338, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/json/decoder.py", line 356, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 2 column 1 (char 1)
===
{'bridge': {'IPAMConfig': None, 'Links': None, 'Aliases': None, 'MacAddress': '02:42:ac:11:00:08', 'DriverOpts': None, 'NetworkID': '10d3793439133021465b92ca7ad4548a4a3d86ade251304fe0c527f61afc3bb2', 'EndpointID': 'd910bcb3b6d9e458c13cb7e4e49a9f975983cca06ddadf14bf336568b43aa18a', 'Gateway': '172.17.0.1', 'IPAddress': '172.17.0.8', 'IPPrefixLen': 16, 'IPv6Gateway': '', 'GlobalIPv6Address': '', 'GlobalIPv6PrefixLen': 0, 'DNSNames': None}}


Explanation:
Der CallKeep-Container ist auf dem Default-Bridge-Netzwerk (172.17.0.8). Andere CasaOS-Apps wie noble_andres und fervent_james sind auf 172.29.0.1 — das ist das CasaOS-Netzwerk. Vielleicht ist das der Knackpunkt: Der Container muss im CasaOS-Bridge-Netzwerk sein, damit die Gateway ihn per localhost-Proxy erreichen kann.

CasaOS-Apps wie DailyDose (noble_andres) laufen auf dem Netzwerk `172.29.0.1`. CallKeep läuft auf dem Default-Bridge `172.17.0.0/16`. Das kann der Grund sein, warum CasaOS ihn nicht aufrufen kann — das Gateway kann Container auf dem Default-Bridge nicht über Docker-DNS erreichen.

Schauen wir, ob das CasaOS-Netzwerk existiert.

sudo docker network ls | grep -E 'casaos|os' ; echo "---" ; sudo docker inspect noble_andres --format '{{.HostConfig.NetworkMode}} {{range .NetworkSettings.Networks}}{{.NetworkID}} {{end}}' 2>/dev/null ; echo "---" ; sudo docker inspect fervent_james --format '{{.HostConfig.NetworkMode}} {{range .NetworkSettings.Networks}}{{.NetworkID}} {{end}}' 2>/dev/null


Output:
721bc0f11bdf   host                     host      local
---

---


Explanation: The issue is that the CallKeep container is on the default `bridge` network, but CasaOS expects containers to be on its own network (like `casaos` or a Docker network it manages). Let me check what network other working containers are on.

sudo docker ps --format '{{.Names}} {{.Networks}} {{.Image}}' | sort


Output:
audiobookshelf audiobookshelf_default ghcr.io/advplyr/audiobookshelf:2.30.0
breathtaking_maryann-main_app-1 bridge ccfb9f0dc7ac
CallKeep bridge callkeep
DocMaster bridge docmaster-docmaster
honcho-db bridge pgvector/pgvector:pg15
honcho-redis bridge redis:8.2
immich-machine-learning immich_immich ghcr.io/immich-app/immich-machine-learning:v2.7.2
immich-postgres immich_immich ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
immich-redis immich_immich valkey/valkey:9
immich-server immich_immich ghcr.io/immich-app/immich-server:v2.7.2
kiwi-browser bridge kiwi-browser:v2
komga bridge gotson/komga:1.23.6
noble_andres-main_app-1 bridge nginx:latest
openclaw bridge icewhaletech/openclaw:2026.5.7
plex host lscr.io/linuxserver/plex:1.41.3
setupos bridge nginx:latest


Explanation:
Let me look at this more carefully. The user says CallKeep shows up in Legacy Apps but isn't clickable. Let me examine the app-management.json more carefully and compare with working v2app entries. The issue might be that CasaOS has a separate way of handling container discovery vs v2apps, or maybe I need to use a custom port format or hostname.

Let me check the CasaOS gateway logs more carefully and compare the CallKeep entry with a working one like DocMaster.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)
# Show DocMaster (working) and CallKeep side by side
for a in apps:
    if a.get('name','') in ('docmaster','callkeep'):
        print(f'=== {a[\"name\"]} ===')
        for k in sorted(a.keys()):
            print(f'  {k}: {json.dumps(a[k])}')
        print()
"


Output:
=== docmaster ===
  app_type: "v2app"
  author_type: "community"
  hostname: ""
  icon: "http://192.168.178.171:3000/icon.svg"
  image: "docmaster-docmaster"
  index: "/"
  is_uncontrolled: false
  name: "docmaster"
  port: "3000"
  scheme: "http"
  status: "running"
  store_app_id: "docmaster"
  title: {"custom": "DocMaster"}


Das Problem: DocMaster nutzt `"title": {"custom": "DocMaster"}`, CallKeep hat `"title": {"en_US": "CallKeep"}`. Das Format muss `custom` sein:

Explanation: The key difference: DocMaster has `"title": {"custom": "DocMaster"}` while CallKeep has `"title": {"en_US": "CallKeep"}`. I need to use the `custom` format.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)
for a in apps:
    if a.get('name') == 'callkeep':
        a['title'] = {'custom': 'CallKeep'}
        print('Fixed title to custom format')
with open('/etc/casaos/app-management.json','w') as f:
    json.dump(apps, f, indent=2)
" && sudo systemctl restart zimaos-gateway && echo "Done"


Output: Done


Explanation: Let me check what other differences might exist between the docmaster entry (working) and the callkeep entry. Let me look more carefully at the full file to see all entries and how they differ.


Explanation: Wait, I notice the CallKeep entry doesn't appear in the file at all now! The list starts with Audiobookshelf. Let me search for it specifically.


Explanation: The search only found one match but the file has it. Let me verify the file is correctly written by searching specifically for "callkeep" or "CallKeep" pattern.

grep -o 'callkeep\|CallKeep' /etc/casaos/app-management.json


Output:
callkeep
CallKeep


Explanation: Let me look at the CasaOS logs to see if there are any errors when loading the CallKeep app entry.

sudo tail -100 /var/log/casaos/gateway.log 2>/dev/null | grep -i -A2 'callkeep\|error\|app-management\|unmarshal\|json.*invalid' || echo "No relevant entries in gateway.log"


Output: No relevant entries in gateway.log


Lass mich den CasaOS-API-Weg probieren statt manuell JSON zu editieren.

Explanation:
Let me think about this differently. The problem is that CallKeep appears in "Legacy Apps" but isn't clickable. Let me look at how CasaOS actually handles this.

The key issue: CallKeep is running on the default `bridge` network, while working apps like DocMaster use the same. But DocMaster was deployed via Docker Compose (`docmaster-docmaster` image name), which creates a project network. CallKeep is a raw `docker run` on the bridge network.

Let me check what CasaOS actually needs. Maybe I need to check the CasaOS gateway API directly to understand how it routes clicks. Or maybe the issue is simpler — the icon URL is wrong (the container can't serve it because `StaticFiles` at `/` might conflict with the icon route).

Wait — let me re-read the icon issue. The `app.mount("/", StaticFiles(...))` at the bottom of the routes means ALL requests go through StaticFiles. API routes are defined BEFORE the mount, so they should take priority. But... `icon.svg` is in the `frontend/` directory, so requesting `/icon.svg` should work because StaticFiles serves from `/app/frontend/`.

Actually, let me check: the `hostname` field is empty `""` for CallKeep vs DocMaster. That should be fine — DocMaster also has empty hostname.

Let me look at what's different. DocMaster was installed through the CasaOS app system (has store_app_id, proper v2app). CallKeep was added manually. Maybe CasaOS ZimaOS expects a different format for manually added apps?

Wait - let me check the ZimaOS API. Let me look at how apps are managed through the API rather than directly editing the file. Maybe I need to use the ZimaOS app management API.

Actually, let me try an entirely different approach. Let me use the ZimaOS API to register the app properly instead of manually editing the JSON file.

curl -s http://localhost/ || echo "no localhost"; curl -s http://localhost:80/ 2>/dev/null | head -c 200 || echo "no :80"; ss -tlnp | grep -E ':80 |:443 |:8080 |:9090' | head -5


Output:
<!doctype html>

    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="./images/zimaos-logo.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="pragram" content="no-cache">
    <meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate">
    <meta http-equiv="expires" content="0">
    <title>ZimaOS</title>

    <link rel="stylesheet" crossorigin href="./assets/index-Ye7ZnEgD.css">

<!doctype html>

    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="./images/zimaos-logo.svg" />
    <meta name="viewport" content="width=device-widtLISTEN 0      0                  *:80               *:*


Explanation: Let me look at the CasaOS API to properly register the app. The ZimaOS dashboard runs on port 80. Let me check if there's an API endpoint to manage apps.

curl -s http://localhost/v2/app_management/container_app | python3 -c "import json,sys; apps=json.load(sys.stdin); print(f'{len(apps.get(\"data\", apps))} apps'); [print(a.get('name',''), a.get('url',''), a.get('app_type',''), a.get('title','')) for a in apps.get('data', [apps]) if 'CallKeep' in str(a)]" 2>/dev/null || echo "v2 failed"; curl -s http://localhost/api/v2/app_management/container_app 2>/dev/null | head -c 300


Output: 1 apps


Explanation:
The API returned 1 app but my grep for CallKeep didn't match anything (empty output). Let me dig deeper into the CasaOS API to understand the schema.

Also, I should try a different approach — maybe I can use the CasaOS API to register/update the CallKeep app properly, rather than manually editing the JSON file which keeps getting overwritten.

Let me also check if there's another JSON file that controls the dashboard.

curl -s http://localhost/v2/app_management/container_app | python3 -m json.tool 2>/dev/null | head -60


Output:
{
    "message": "no matching operation was found"
}


Explanation: Let me try to find the correct CasaOS API endpoint for container management.

curl -s http://localhost/v1/container/list 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); [print(a['name'], a.get('state','')) for a in d['data'] if 'CallKeep' in a.get('name','')]" 2>/dev/null || echo "v1 fail"; curl -s http://localhost/v2/container 2>/dev/null | head -c 300


Output: v1 fail


Explanation: Let me approach this differently. CasaOS auto-detects containers and creates legacy entries. The issue might be that CasaOS doesn't recognize the docker labels I set. Let me check how DocMaster's docker labels look compared to CallKeep's. DocMaster works fine and shows up clickable. Let me inspect what makes DocMaster different.

sudo docker inspect DocMaster --format '{{json .Config.Labels}}' | python3 -m json.tool


Output:
{
    "com.docker.compose.config-hash": "cf8c5044cf819d82558b86cdf7afb0378f50106dcd9a899ec5044fbaa6a2ba4d",
    "com.docker.compose.container-number": "1",
    "com.docker.compose.depends_on": "",
    "com.docker.compose.image": "sha256:6b5f1f35a99fea6c6b3bb9c6843b261d3c6dc3540c821f67b315a1d17a763c13",
    "com.docker.compose.oneoff": "False",
    "com.docker.compose.project": "docmaster",
    "com.docker.compose.project.config_files": "/var/lib/casaos/apps/DocMaster/docker-compose.yml",
    "com.docker.compose.project.working_dir": "/var/lib/casaos/apps/DocMaster",
    "com.docker.compose.service": "DocMaster",
    "com.docker.compose.version": "2.32.4"
}


Explanation:
ZimaOS/CasaOS uses a different mechanism for legacy apps. The app-management.json v2app entries are for apps that were installed through CasaOS's app store. But for custom Docker containers, ZimaOS auto-detects them and shows them in a "Legacy" panel. The auto-detection uses Docker labels like `casaos.*` — those get parsed by ZimaOS's container scanner.

The issue is: auto-detected containers appear under "My Legacy Apps" and clicking them is supposed to forward to `http://<ip>:<port>/`. But it might not be working because:

1. The container might need to be on the `bridge` network OR have the correct network settings
2. The port might not be accessible from the host

Let me check if the app-management.json has a separate "Legacy" mechanism (maybe the `app_type: "container"` entries I saw earlier ARE the legacy apps, and they get their URL from Docker inspection, not from the JSON).

Wait - I see earlier the file only had ONE CallKeep entry after my fix (the v2app with name: callkeep). But before my fix, CasaOS had auto-created an `app_type: "container"` entry (the `dc504d...` docker ID one). Maybe I need BOTH — the container entry for auto-detection AND the v2app entry for the clickable tile?

Actually no, let me think again. The `app_type: "container"` entries were auto-generated by ZimaOS scanning Docker. Those are what show up in "My Legacy Apps". The problem is that clicking the tile doesn't redirect.

The key issue: I changed the auto-detected `app_type: "container"` entry into a `v2app`. But that breaks the legacy app mechanism — it's no longer auto-detected by ZimaOS's Docker scanner, and it's also not a real v2app (no docker-compose.yml in /var/lib/casaos/apps/).

The fix should be: restore the auto-detected container entry AS IS (with the docker container ID as name), but the question is why clicking doesn't work.

Looking at the Docker labels I have:
- `casaos.title` = "CallKeep" ✓
- `casaos.port` = "3002" ✓
- `casaos.scheme` = "http" ✓
- `casaos.hostname` = "" ✓
- `casaos.icon` = "http://192.168.178.171:3002/icon.svg" ✓
- `casaos.index` = "/" ✓
- `casaos.description` = ...

Maybe the issue is the blank hostname. Let me check the `openclaw` entry - it uses https scheme but no hostname. DocMaster has empty hostname. Let me look at `plex`... it has empty hostname too.

Actually, I think the issue might be that the `app_type: "container"` entry was correct and the `v2app` entry is confusing ZimaOS. Let me restore the container entry and remove the v2app one.

Let me look at the app-management.json more carefully. The current state has both types. Let me check what the other `container` entries look like and if they're clickable.

Actually, let me try a completely different approach: use CasaOS REST API to add the app properly.

# DocMaster ist via docker-compose in /var/lib/casaos/apps/ installiert. CallKeep ist nur docker run.
# Prüfe, ob der Legacy-Auto-Eintrag den richtigen Port bekommt:
sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)
# Zeige ALLE container-type Einträge (auto-detected legacy)
for a in apps:
    if a.get('app_type') == 'container':
        print(json.dumps(a, indent=2))
        print('---')
"


Output:
{
  "app_type": "container",
  "image": "callkeep",
  "is_uncontrolled": false,
  "name": "dc504d35972239642cf9bd2495025fe7da77f02c9beb33291d3d5b677007dc9a",
  "status": "running",
  "title": {
    "en_US": "CallKeep"
  }
}
---
{
  "app_type": "container",
  "image": "redis:8.2",
  "is_uncontrolled": false,
  "name": "706dad21d7e6d59beefa977a98ef58be76b94e34e174bd651ea749c0432bbbbd",
  "status": "running",
  "title": {
    "en_US": "honcho-redis"
  }
}
---
{
  "app_type": "container",
  "image": "pgvector/pgvector:pg15",
  "is_uncontrolled": false,
  "name": "b887bf0d0c26f1e4fd35037567e4caf2094a0daad59e526b70f6201fa8726f15",
  "status": "running",
  "title": {
    "en_US": "honcho-db"
  }
}
---
{
  "app_type": "container",
  "image": "kiwi-browser:v2",
  "is_uncontrolled": false,
  "name": "ce7d9a5d4d8a88f703df2e86d89ddaf3099147fd0ff6c1b8d2df103e0ccd1639",
  "status": "running",
  "title": {
    "en_US": "kiwi-browser"
  }
}
---


Das Problem ist, dass der Legacy `container`-Typ keinen `port`/`scheme`/`index` hat — CasaOS weiß nicht, wohin es verlinken soll. Ich ergänze diese Felder direkt im Container-Eintrag:

Explanation:
Das Problem ist klar: Der Legacy `container`-Typ hat keinen `port`, `scheme`, `hostname`, oder `index`. CasaOS weiß nicht, wohin es linken soll. Ich muss entweder:

1. Den `container`-Eintrag um die fehlenden Felder ergänzen (wenn der Legacy-Typ sie akzeptiert)
2. Oder den Container so taggen, dass CasaOS ihn richtig erkennt

Lass mich versuchen, den `container`-Eintrag direkt mit den nötigen Feldern zu patchen:

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)

for a in apps:
    if a.get('name','').startswith('dc504d359722') and a.get('app_type') == 'container':
        a['port'] = '3002'
        a['scheme'] = 'http'
        a['hostname'] = ''
        a['index'] = '/'
        a['icon'] = 'http://192.168.178.171:3002/icon.svg'
        print('Patched:', json.dumps(a, indent=2))
        break

with open('/etc/casaos/app-management.json','w') as f:
    json.dump(apps, f, indent=2)
" && sudo systemctl restart zimaos-gateway && echo "Gateway restarted"


Output:
Patched: {
  "app_type": "container",
  "image": "callkeep",
  "is_uncontrolled": false,
  "name": "dc504d35972239642cf9bd2495025fe7da77f02c9beb33291d3d5b677007dc9a",
  "status": "running",
  "title": {
    "en_US": "CallKeep"
  },
  "port": "3002",
  "scheme": "http",
  "hostname": "",
  "index": "/",
  "icon": "http://192.168.178.171:3002/icon.svg"
}
Gateway restarted


Explanation:
Der Eintrag ist jetzt als `app_type: "container"` mit port, scheme, hostname, index, icon. Gateway neugestartet.

Aber der DocMaster-Eintrag war auch mal Legacy und wurde dann zu v2app. Vielleicht reicht das einfache Hinzufügen der Felder nicht — das Gateway erwartet vielleicht das v2app-Format für anklickbare Apps. Lass mich den Eintrag komplett in v2app umbauen und den container-Eintrag entfernen.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)

# Remove ALL callkeep entries (old v2app + container)
before = len(apps)
apps = [a for a in apps if a.get('name','') not in ('callkeep',) and not a.get('name','').startswith('dc504d359722')]
print(f'Removed {before - len(apps)} old entries')

# Add clean v2app entry
apps.append({
    'app_type': 'v2app',
    'author_type': 'community',
    'hostname': '',
    'icon': 'http://192.168.178.171:3002/icon.svg',
    'image': 'callkeep',
    'index': '/',
    'is_uncontrolled': False,
    'name': 'callkeep',
    'port': '3002',
    'scheme': 'http',
    'status': 'running',
    'store_app_id': 'callkeep',
    'title': {'custom': 'CallKeep'}
})

with open('/etc/casaos/app-management.json','w') as f:
    json.dump(apps, f, indent=2)
print(f'Added v2app entry. {len(apps)} apps total.')
" && sudo systemctl restart zimaos-gateway && echo "Gateway restarted"


Output:
Removed 1 old entries
Added v2app entry. 13 apps total.
Gateway restarted


Explanation:
CallKeep shows up as Legacy App and is not clickable. The core issue seems to be that CasaOS's auto-detection of the container (app_type: "container") overrides or conflicts with my manual v2app entry, and the Legacy App lacks port/scheme/index fields. But I already fixed that multiple times and it still doesn't work.

Let me look at this differently. The fact that it shows as "Legacy App" but is not clickable suggests that CasaOS is detecting the docker container but can't construct a URL from it. Wait — I should check: does the CasaOS gateway actually see my v2app entry correctly? Let me look at the gateway API directly to see what it returns for the apps list.

Actually, let me check one more thing — maybe the gateway caches stale config or the format requires `title` to have specific locales. Also, let me look at what the ZimaOS web app skill says about registering custom apps.


zimaos-web-app

---
name: zimaos-web-app
version: 1.0.0
description: Build small single-file web apps for ZimaOS using FastAPI + vanilla HTML/CSS/JS. Apple-inspired minimal design, mobile-optimized, no auth unless specified.
author: Kiwi
metadata:
  hermes:
    tags: [zimaos, web-app, fastapi, html, mobile]
---

# ZimaOS Web App

## Purpose
Build small, self-contained web applications that run on ZimaOS under `/DATA/AppData/hermes/projects/`. The pattern: one Python file (FastAPI backend + static serving) + one HTML file (frontend). Accessible from any device on the local network. No Docker, no Node.js, no build step.

## When to Use
Use when the user asks for a "small web app", "tool", "web-ui", or any browser-based utility that runs on their ZimaOS server.

## Design Rules (user preferences)

**UI Style (primary — Zeyd's blue glass-morphism, system-aware theme):**
- **System-aware:** Always implement light mode as default with `@media (prefers-color-scheme: dark)` overrides. Never hardcode dark-only. User wants the app to match the device's system setting.
- Blue color scheme: primary `#2563EB`, accent `#3B82F6`, dark accent `#1E3A5F`
- Glass-morphism: use CSS variables for `card-glass`, `card-border`, `modal-bg` that change between light/dark
- Light mode: `--bg: #F8FAFC`, `--card-glass: rgba(255,255,255,0.75)`, `--card-border: rgba(0,0,0,0.06)`
- Dark mode: `--bg: #0A0E1A`, `--card-glass: rgba(255,255,255,0.06)`, `--card-border: rgba(255,255,255,0.08)`
- Mobile-first with safe-area insets (`env(safe-area-inset-*)`), viewport-fit=cover
- Bottom-sheet modals with drag handle (slide-up from bottom, max-height 88vh)
- Rounded corners: cards 18px, modal top 22px, buttons 12px
- Touch-friendly: minimum 44px tap targets, card `:active` scale(0.97) feedback
- Smooth transitions: `cubic-bezier(0.25, 0.1, 0.25, 1)`
- Reference apps: DocMaster, DailyDose, PrintVault (style orientation)

**UI Style (alternative — Stripe Link, warm & editorial):**
- Inspired by Stripe Link (link.com): warm off-white background, clean cards, serif font for headlines, system font for UI
- Color palette: background `#fdf5ef`, cards `#FFFFFF`, text `#13343B`, dim text `#555`
- Accent blue: `#00579F`, hover `#003F75`, light blue `rgba(0,87,159,0.08)`
- Functional colors: red `#EF4562` (Stripe's warm red, not harsh), green `#0E6245` (deep), teal accent `#1FB8CD`
- Typography: **Lora** (Google Fonts serif, 400/500/600/700) for headlines and large numbers, `-apple-system` for UI labels and body text. Load via Google Fonts CDN preconnect + link
- Cards: `border-radius 12px`, `box-shadow: 0 1px 3px rgba(19,52,59,0.06)`, subtle border `rgba(19,52,59,0.08)`, white background
- Header: sticky, `backdrop-filter blur(16px)` on `rgba(253,245,239,0.92)`, thin bottom border
- No dark mode (Stripe Link is light-only), no radial gradients
- Much white space, large serif numbers for hero values, minimal icon usage
- Use when user says "wie Stripe Link", "link.com/at style", or requests warm/editorial/serif design for finance/docs apps
- SF Pro fonts, rounded corners (16px), subtle shadows, frosted glass header
- Color palette: background `#F2F2F7`, cards `#FFFFFF`, text `#1D1D1F`, blue `#007AFF`, green `#34C759`, red `#FF3B30`
- Use only when user explicitly requests Apple/light-minimal style

**Auth:**
- No authentication unless the user explicitly requests it
- If auth needed, use HTTP Basic Auth middleware (like DocMaster)

## Deployment Patterns

### Option A: Direct Python (quick dev)
Run via: `python main.py` (uses uvicorn in `__main__`).
Port: pick an unused port near 3000 (check with `ss -tlnp | grep 300`).

### Option B: Docker Container (production, user preference)
Use when the user wants a persistent container with CasaOS dashboard integration.

**Build (ZimaOS quirks):**
```bash
# ZimaOS has read-only /root/.docker — must use HOME=/tmp
# User az-a is NOT in docker group — always sudo
sudo env HOME=/tmp docker build -t <appname> /DATA/AppData/hermes/projects/<app>
```

**Deploy — choose pattern A or B:**

See *Two valid deployment patterns* below for complete instructions with correct hostname/icon conventions.

**Dockerfile (if needed):**
```dockerfile
FROM python:3.11-slim
RUN pip install --no-cache-dir fastapi uvicorn <extra-deps>
WORKDIR /app
COPY main.py .
COPY frontend/ frontend/
ENV MPLCONFIGDIR=/tmp/matplotlib  # if using matplotlib
EXPOSE <port>
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "<port>"]
```

## CasaOS Dashboard Integration

### "Derzeit nicht unterstützt" — Root Cause & Complete Fix

When clicking an app tile in CasaOS shows "Derzeit nicht unterstützt" (currently not supported), there are **two layers** to the fix. Apply both:

**Layer 1: CSP iframe middleware** — allow CasaOS to embed your app:
```python
@app.middleware("http")
async def add_frame_options(request: Request, call_next):
    """Allow iframe embedding for CasaOS dashboard."""
    response = await call_next(request)
    response.headers["X-Frame-Options"] = "ALLOWALL"
    response.headers["Content-Security-Policy"] = "frame-ancestors 'self' http://*:* https://*:*;"
    return response
```

**Layer 2: App type registration** — this is the REAL root cause. `zimaos-app-management` discovers containers by scanning Docker labels and compose files. It assigns an `app_type`:

- `container` — discovered via direct `docker run` (no `com.docker.compose.*` labels). Works. The gateway reads `casaos.*` Docker labels directly for routing.
- `v2app` — discovered via compose files with `com.docker.compose.project` labels. Works when a proper `x-casaos` extension block is present in the compose file. Without `x-casaos`, the service logs `error: "extension x-casaos not found"` → dashboard shows "Derzeit nicht unterstützt".

**Both patterns work:** `docker run` with `casaos.*` labels (Pattern A, no compose needed) OR compose file with `x-casaos` block (Pattern B). Never use compose WITHOUT `x-casaos`.

**Complete fix — switch from compose to docker run, then clean up app-management.json:**
```bash
# 1. Stop and remove compose-managed container
sudo docker stop <AppName> 2>/dev/null
sudo docker rm <AppName> 2>/dev/null

# 2. Start directly with casaos.* labels only (NO compose labels)
sudo docker run -d \
  --name <AppName> \
  --network bridge \
  --restart unless-stopped \
  -p <port>:<port> \
  -v /media/HDD_1TB/<data-dir>:/data \
  -e PORT=<port> \
  -l casaos.title="<App Name>" \
  -l casaos.description="<description>" \
  -l casaos.icon="http://localhost:<port>/icon.svg" \
  -l casaos.scheme="http" \
  -l casaos.hostname="" \
  -l casaos.port="<port>" \
  -l casaos.index="/" \
  -l casaos.category="Utilities" \
  <image-name>

# 3. Stop app-management, remove old v2app entries, restart
sudo systemctl stop zimaos-app-management
sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)
# Remove old v2app entries by name AND old container hash entries
apps = [a for a in apps if a.get('name','') not in ('<appname>','<old-hash>')]
with open('/etc/casaos/app-management.json','w') as f:
    json.dump(apps, f, indent=2)
"
sudo systemctl start zimaos-app-management
sleep 5
sudo systemctl restart zimaos-gateway

# 4. Verify: both apps should now be type 'container'
sudo docker ps --format '{{.Names}} {{.Status}}' | grep -E '<AppName>'
# Also check no x-casaos errors in logs:
sudo journalctl -u zimaos-app-management --since "1 min ago" --no-pager | grep -i x-casaos
```

**Critical details:**
- Stop app-management BEFORE editing the JSON — the service overwrites it from memory on shutdown. Edit while stopped, then start.
- Remove ALL old entries for the app (both the `v2app` by name and any stale `container` entry by Docker hash ID). Otherwise duplicates appear.
- `zimaos-gateway` must be restarted after app-management to pick up the new routes. Gateway caches are the #1 cause of "fixed JSON but still shows error".
- This approach survives reboots — `docker run` with `--restart unless-stopped` re-launches the container with the same labels. No compose file needed.

**Verification — check the appgrid API:**
```bash
PORT=$(sudo ss -tlnp | grep zimaos-app-man | awk '{print $4}' | cut -d: -f2 | head -1)
curl -s http://127.0.0.1:$PORT/v2/app_management/web/appgrid | python3 -c "
import json,sys
for item in json.load(sys.stdin)['data']:
    if item.get('app_type') == 'container':
        print(item.get('title',{}).get('en_US','?'), '-> type:', item.get('app_type'))
"
```

### network_mode: bridge

Apps deployed via docker compose get isolated networks by default (e.g., `printvault_default` on 172.19.x). CasaOS can only reach containers on the default `bridge` network. Add to docker-compose.yml:
```yaml
    network_mode: bridge
```

### Two valid deployment patterns

**Pattern A: `container` type via `docker run` + `casaos.*` labels** — recommended for simple apps. Container is discovered via Docker daemon scanning and registered as `container` type. Routing data is read directly from Docker labels at gateway time — no compose file needed.

```bash
sudo docker run -d \
  --name <AppName> --network bridge --restart unless-stopped \
  -p <port>:<port> -e PORT=<port> \
  -l casaos.title="<App Name>" \
  -l casaos.icon="http://<SERVER_IP>:<port>/icon.svg" \
  -l casaos.scheme="http" \
  -l casaos.hostname="<SERVER_IP>" \
  -l casaos.port="<port>" \
  -l casaos.index="/" \
  -l casaos.category="Utilities" \
  <image-name>
```

**Pattern B: `v2app` type via `x-casaos` compose file** — for apps that need complex compose config (volumes, env files, depends_on). Place a `docker-compose.yml` in `/var/lib/casaos/apps/<StoreAppID>/` with an `x-casaos` top-level block. The service discovers it and registers as `v2app` type with all fields populated from the `x-casaos` block. **This works — the "never compose" rule below was wrong. See Pitfalls for the distinction.**

Example `/var/lib/casaos/apps/PrintVault/docker-compose.yml`:
```yaml
name: printvault
services:
  PrintVault:
    image: printvault
    network_mode: bridge
    container_name: PrintVault
    ports: ["3001:3001"]
    environment: [PORT=3001]
    restart: unless-stopped
x-casaos:
  author: self
  category: self
  hostname: ""    # empty = works everywhere (LAN, VPN, remote) — all official apps use this
  icon: http://192.168.178.171:3001/icon.svg
  index: /
  is_uncontrolled: false
  port_map: "3001"
  scheme: http
  store_app_id: printvault
  title:
    custom: PrintVault
```

**Pattern B deployment steps (proven working):**
```bash
# 1. Write compose file with x-casaos block (see template above)
sudo mkdir -p /var/lib/casaos/apps/<StoreAppID>
sudo tee /var/lib/casaos/apps/<StoreAppID>/docker-compose.yml << 'EOF'
# Top-level name must match store_app_id for discovery
name: <store-app-id>
services:
  ...
x-casaos:
  store_app_id: <store-app-id>
  ...
EOF

# 2. Copy source files into the deploy directory (if building from source)
sudo cp <source>/main.py /var/lib/casaos/apps/<StoreAppID>/main.py
sudo cp -r <source>/frontend/ /var/lib/casaos/apps/<StoreAppID>/frontend/

# 3. Stop app-management, rebuild image (--no-cache required!), restart container
sudo systemctl stop zimaos-app-management
cd /var/lib/casaos/apps/<StoreAppID> && sudo docker compose down
cd /var/lib/casaos/apps/<StoreAppID> && sudo env DOCKER_CONFIG=/tmp/docker-config docker compose build --no-cache
cd /var/lib/casaos/apps/<StoreAppID> && sudo docker compose up -d
sudo systemctl start zimaos-app-management
sleep 6

# 4. Restart gateway (MANDATORY — routes are cached)
sudo systemctl restart zimaos-gateway
sleep 8

# 5. Verify the app appears in installed list
PORT=$(sudo ss -tlnp | grep zimaos-app-man | awk '{print $4}' | cut -d: -f2 | head -1)
curl -s "http://127.0.0.1:$PORT/v2/app_management/apps" | python3 -c "
import json,sys
data=json.load(sys.stdin)
print('Installed:', data['data']['installed'])
"
```

**⚠ Critical: When source files change, you MUST rebuild.** `docker compose up -d` alone reuses the cached image. If you copied new source files but didn't rebuild, the app runs the old code — this is the #1 cause of "app works but looks old/wrong". Always run `build --no-cache` before `up -d` when files changed.

**Why compose without x-casaos fails:** Docker compose generates `com.docker.compose.*` labels even without an `x-casaos` block. These labels trigger `v2app` registration, but without the `x-casaos` extension the app-management service logs `error: "extension x-casaos not found"` → dashboard shows "Derzeit nicht unterstützt". The fix is either: (A) switch to `docker run` with only `casaos.*` labels, OR (B) add the proper `x-casaos` block to the compose file.

Manual edits to `/etc/casaos/app-management.json` are **reverted by the service on restart** because it regenerates the JSON from in-memory state. To clean up old entries: stop the service first (`sudo systemctl stop zimaos-app-management`), edit the JSON, then start.

Note: `title` in app-management.json is an i18n dict (e.g. `{"en_US": "PrintVault"}` or `{"custom": "PrintVault"}`), NOT a flat string — parse with `.get('en_US', '') or .get('custom', '')`, never cast directly. To verify app is registered, query the simpler `installed` list endpoint: `/v2/app_management/apps` → `data.installed` is a flat array of store_app_id strings.

### Icon & hostname — critical correctness

**`hostname` field determines iframe URL construction.** The CasaOS frontend uses it to build the URL that loads your app in an iframe:
- `hostname=""` (empty string) → frontend uses `window.location.hostname` (the same host the dashboard was accessed from) → works for both LAN and VPN/remote access ✅ (**RECOMMENDED** — all official apps like Plex, Immich, Komga use this)
- `hostname="localhost"` → frontend constructs `localhost:<port>` → resolves to **the client browser's machine**, NOT the ZimaOS server → blank page ❌
- `hostname="192.168.178.171"` (hardcoded LAN IP) → works on LAN but **breaks over VPN/remote access** (client browser can't reach the LAN IP through the VPN tunnel)
- `hostname="172.17.0.x"` (Docker bridge IP) → only reachable from within Docker network → fails from client browser

**Icon URL follows a different rule:** the icon must be reachable from the client browser. For LAN-only access, use the server's LAN IP (e.g. `http://192.168.178.171:<port>/icon.svg`). Over VPN, the LAN IP icon URL won't load — but the app still works (icon is cosmetic). If you need universal icon reachability, serve it through the gateway proxy or use a data URI. For simple apps, the LAN IP icon is fine.

For both patterns, determine server IP: `ip route get 1 2>/dev/null | awk '{print $7}' | head -1`

Serve a local SVG icon at `/icon.svg` (don't use external CDN links — they may fail on some ZimaOS builds):

```python
@app.get("/icon.svg")
def serve_icon():
    return FileResponse(FRONTEND_DIR / "icon.svg", media_type="image/svg+xml")
```

Then reference it with the server's LAN IP in your deployment labels/block: `casaos.icon: "http://<SERVER_IP>:<port>/icon.svg"` (see Icon & hostname section above for why localhost doesn't work).

## Project Structure

**Primary (Source + Data on HDD):** When the user says "alles in einen Ordner" or wants consolidation, put everything on the HDD:

```
/media/HDD_1TB/<AppName>/
├── main.py              # FastAPI app — all backend logic
├── Dockerfile           # Container build
├── metadata.json        # Persistent data (if any)
├── stls/                # Uploaded files (if app handles files)
├── thumbs/              # Generated thumbnails
└── frontend/
    ├── index.html       # Single HTML file — inline CSS + JS
    └── icon.svg         # App icon
```

The `docker-compose.yml` stays in `/var/lib/casaos/apps/<AppName>/` with `build.context` pointing to the HDD path:
```yaml
services:
  <appname>:
    build:
      context: /media/HDD_1TB/<AppName>
    volumes:
      - /media/HDD_1TB/<AppName>:/data
    environment:
      - DATA_DIR=/data
```

This keeps all files on the durable HDD (survives OS reinstalls) while CasaOS still discovers the app via its compose file. The volume mount gives the container access to the same directory as `/data`, and the app uses `DATA_DIR=/data` to find its data.

**Secondary (Project on SSD, data on HDD):** For simpler apps where the user hasn't asked for consolidation:

```
/DATA/AppData/hermes/projects/<app-name>/
├── main.py              # FastAPI app — all backend logic
└── frontend/
    └── index.html       # Single HTML file — inline CSS + JS
```

With data on HDD via volume mount: `-v /media/HDD_1TB/<data-dir>:/data`.

## FastAPI Backend Template

Use `StaticFiles` mount **at the end of the file** (after all API routes) so API endpoints take priority. The `html=True` flag automatically serves `index.html` for directory requests — no explicit `@app.get("/")` needed.

```python
"""
<App Name> — Description
FastAPI Backend + Static Frontend
"""
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware

FRONTEND_DIR = Path(__file__).parent / "frontend"

app = FastAPI(title="<App Name>")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

# ── API routes (define BEFORE the StaticFiles mount) ─────────────

@app.get("/api/stats")
def stats():
    return {"status": "ok"}

# ── Static files (mount AFTER all API routes) ────────────────────
if FRONTEND_DIR.exists():
    app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")

# ── RUN ───────────────────────────────────────────────────────────
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=3001)
```

## Common API Patterns

Add to any media/file-library app's backend:

**File download endpoint:**
```python
@app.get("/api/download/{file_id}")
def download_file(file_id: str):
    file_path = STORAGE_DIR / f"{file_id}.<ext>"
    if not file_path.exists():
        raise HTTPException(404, "Datei nicht gefunden")
    meta = load_meta()
    display_name = meta.get(file_id, {}).get("name", file_id).replace(" ", "_") + ".<ext>"
    return FileResponse(file_path, media_type="application/octet-stream", filename=display_name)
```

## Frontend Skeleton (Primary — Blue Glass-Morphism, System-Aware Light/Dark)

This is the DEFAULT skeleton. All new apps use this unless the user explicitly asks for the Apple-minimal light variant.

**Critical: System-aware theme.** Always use `@media (prefers-color-scheme: dark)` with light-as-default variables. Never hardcode dark-only. The user wants the app to match the device's system setting.

```html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<title>App Name</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

/* ── Light Mode (default) ──────────────────── */
:root {
  --blue-400: #3B82F6; --blue-500: #2563EB;
  --blue-700: #1E3A5F; --blue-900: #0F1B2D;
  --green: #16A34A; --orange: #D97706; --red: #DC2626;
  --bg: #F8FAFC;
  --card-glass: rgba(255,255,255,0.75);
  --card-border: rgba(0,0,0,0.06);
  --text: #0F172A; --text-dim: #475569; --text-muted: #94A3B8;
  --modal-bg: rgba(255,255,255,0.92);
  --radius: 18px; --radius-sm: 12px; --radius-xs: 8px;
  --transition: 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);
}

/* ── Dark Mode ────────────────────────────── */
@media (prefers-color-scheme: dark) {
  :root {
    --green: #22C55E; --orange: #F59E0B; --red: #EF4444;
    --bg: #0A0E1A;
    --card-glass: rgba(255,255,255,0.06);
    --card-border: rgba(255,255,255,0.08);
    --text: #F1F5F9; --text-dim: #94A3B8; --text-muted: #64748B;
    --modal-bg: rgba(15,23,42,0.95);
  }
}

html, body {
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, sans-serif;
  background: var(--bg); color: var(--text);
  min-height: 100vh; min-height: 100dvh;
  -webkit-font-smoothing: antialiased;
}
body::before {
  content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 0;
  background:
    radial-gradient(ellipse 80% 50% at 20% 0%, rgba(37,99,235,0.12) 0%, transparent 60%),
    radial-gradient(ellipse 60% 40% at 90% 100%, rgba(30,58,95,0.10) 0%, transparent 60%);
}
@media (prefers-color-scheme: dark) {
  body::before {
    background:
      radial-gradient(ellipse 80% 50% at 20% 0%, rgba(37,99,235,0.18) 0%, transparent 60%),
      radial-gradient(ellipse 60% 40% at 90% 100%, rgba(30,58,95,0.25) 0%, transparent 60%);
  }
}

header {
  position: sticky; top: 0; z-index: 200;
  background: var(--card-glass);
  backdrop-filter: blur(24px) saturate(180%);
  -webkit-backdrop-filter: blur(24px) saturate(180%);
  border-bottom: 1px solid var(--card-border);
  padding: 14px 20px;
}
.header-inner { max-width: 800px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; position: relative; z-index: 1; }
.header-inner h1 {
  font-size: 20px; font-weight: 700;
  background: linear-gradient(135deg, #FFFFFF 0%, #93C5FD 100%);
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
  background-clip: text; letter-spacing: -0.3px;
}
.content { position: relative; z-index: 1; max-width: 800px; margin: 0 auto; padding: 20px; }

.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px; }
.card {
  background: var(--card-glass); border: 1px solid var(--card-border);
  border-radius: var(--radius); overflow: hidden; transition: var(--transition);
  cursor: pointer; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
}
.card:hover { border-color: rgba(59,130,246,0.35); box-shadow: 0 8px 24px rgba(37,99,235,0.1); }
.card:active { transform: scale(0.97); }

.modal-overlay {
  position: fixed; inset: 0; z-index: 500;
  background: rgba(0,0,0,0.5); backdrop-filter: blur(6px);
  display: flex; align-items: flex-end; justify-content: center;
  animation: fadeIn 0.2s ease;
}
.modal {
  background: var(--modal-bg); backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid var(--card-border);
  border-radius: 22px 22px 0 0; width: 100%; max-width: 540px;
  max-height: 88vh; overflow-y: auto;
  animation: slideUp 0.35s cubic-bezier(0.32, 0.72, 0, 1);
  padding: 24px 20px 44px;
  box-shadow: 0 -10px 40px rgba(0,0,0,0.3);
}
.modal-handle {
  width: 36px; height: 4px; border-radius: 2px;
  background: var(--card-border); margin: 0 auto 20px;
}
@keyframes fadeIn { from { opacity: 0; } }
@keyframes slideUp { from { transform: translateY(100%); } }

.toast {
  position: fixed; top: 24px; left: 50%; transform: translateX(-50%);
  background: var(--modal-bg); backdrop-filter: blur(12px);
  border: 1px solid var(--card-border); color: var(--text);
  padding: 12px 22px; border-radius: 30px; font-size: 13px; font-weight: 600;
  z-index: 1100; box-shadow: 0 8px 32px rgba(37,99,235,0.15);
  animation: toastIn 0.3s ease, toastOut 0.3s ease 2.2s forwards;
}
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-12px); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(-12px); } }

@media (max-width: 400px) {
  .grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
  .header-inner h1 { font-size: 17px; }
}
</style>
</head>
<body>
<header><div class="header-inner"><h1>📦 App Name</h1></div></header>
<div class="content"><!-- app content --></div>
</body>
</html>
```

### Frontend Skeleton (Alternative — Apple-Inspired Light)

Use ONLY when user explicitly requests Apple/light-minimal style. This is NOT the default.

```html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>App Name</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
  --blue: #007AFF; --red: #FF3B30; --green: #34C759;
  --bg: #F2F2F7; --card: #FFFFFF; --text: #1D1D1F; --secondary: #8E8E93;
  --radius: 16px; --radius-sm: 10px; --shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
  --transition: 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
}
body {
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
  background: var(--bg); color: var(--text); min-height: 100vh;
  -webkit-font-smoothing: antialiased;
}
header {
  position: sticky; top: 0;
  background: rgba(242,242,247,0.85);
  backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
  border-bottom: 1px solid rgba(0,0,0,0.06); padding: 12px 20px;
}
.header-inner { max-width: 800px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }
.header-inner h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
.content { max-width: 800px; margin: 0 auto; padding: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px; }
@media (max-width: 400px) { .grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; } }
.modal-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 500;
  display: flex; align-items: flex-end; justify-content: center;
  backdrop-filter: blur(4px); animation: fadeIn 0.2s ease;
}
.modal {
  background: var(--card); border-radius: var(--radius) var(--radius) 0 0;
  width: 100%; max-width: 500px; max-height: 85vh; overflow-y: auto;
  animation: slideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1);
  padding: 24px 20px 40px; -webkit-overflow-scrolling: touch;
}
@keyframes fadeIn { from { opacity: 0; } }
@keyframes slideUp { from { transform: translateY(100%); } }
.toast {
  position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
  background: var(--text); color: white; padding: 12px 24px;
  border-radius: 30px; font-size: 14px; font-weight: 500; z-index: 1000;
  animation: toastIn 0.3s ease, toastOut 0.3s ease 2s forwards;
}
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-10px); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(-10px); } }
@media (prefers-color-scheme: dark) {
  :root { --bg: #1C1C1E; --card: #2C2C2E; --text: #FFFFFF; --secondary: #98989D; }
  header { background: rgba(28,28,30,0.85); }
}
</style>
</head>
<body>
<header><div class="header-inner"><h1>📦 App Name</h1></div></header>
<div class="content"><!-- app content --></div>
</body>
</html>
```

### Full-width design (when user asks "nutze die gesamte Breite")

When the user requests full-width layout, the correct approach is:
- **Remove** `max-width: 800px` from `.header-inner`, `.controls`, `.upload-zone`, `.grid`
- **Keep** the natural 20px content padding (like `margin: 0 20px` or inherited from `header { padding: 12px 20px }`)
- **Do NOT** replace with `max-width: 100%` + larger fixed margins (like `32px`) — that creates awkward spacing on mobile and conflicts with the header's own padding, resulting in 52px total offset from the edge. User will say "Sieht irgendwie nicht richtig aus."
- The grid's `auto-fill` and card sizes already handle responsive width naturally — removing the max-width constraint is sufficient.

## Checklist

Before declaring done, verify:
- [ ] Port is free (`ss -tlnp`)
- [ ] `curl localhost:<port>/` returns HTML
- [ ] `curl localhost:<port>/api/stats` returns JSON
- [ ] Background process is running and not blocked on permissions
- [ ] HDD paths exist with correct ownership (`sudo mkdir` + `sudo chown az-a:users` if needed)
- [ ] Mobile viewport meta tag present
- [ ] No external CSS/JS dependencies (no CDN links)
- [ ] Toast notifications work
- [ ] Card :active feedback works on mobile

## Pitfalls

| Symptom | Cause | Fix |
|---------|-------|-----|
| `PermissionError` on `/media/HDD_1TB/` | Root-owned mount, user `az-a` can't write | `sudo mkdir -p <path>` + `sudo chown az-a:users <path>` |
| Port already in use | Another service | Pick next available (3002, 3003…) |
| Frontend not found (404) | Wrong FRONTEND_DIR path | Use `Path(__file__).parent / "frontend"` |
| `ImportError: Library "GL" not found` | pyglet needs OpenGL on headless ZimaOS | Use matplotlib with `Agg` backend for 3D thumbnails instead of `trimesh.scene.save_image()` |
| Matplotlib permission warning | Can't write `~/.config/matplotlib` | Set `MPLCONFIGDIR=/tmp` — harmless |
| Background process dies immediately | Module import error (missing dep) | Check `process log` for traceback |
| Docker build fails with `read-only file system` or `permission denied` on `/tmp/docker-config/buildx/instances` | ZimaOS has `/root/.docker` on read-only FS. `DOCKER_CONFIG=/tmp/docker-config` needs a writable directory owned by the user running docker (not root). | **Full sequence — works for both `docker build` and `docker compose build`:** (1) `sudo rm -rf /tmp/docker-config` (may exist with root ownership from prior sudo invocations), (2) `sudo mkdir -p /tmp/docker-config`, (3) `sudo chown -R az-a:docker /tmp/docker-config` (critical — docker needs to write buildx instances), (4) `DOCKER_CONFIG=/tmp/docker-config docker build -t <image> <context>`. For compose: same prep + `DOCKER_CONFIG=/tmp/docker-config docker compose -f <file> build --no-cache`. The `HOME` variable alone is NOT sufficient for compose — `DOCKER_CONFIG` overrides where compose writes its config. `sudo env` is NOT needed when the directory is chown'd to az-a. |
| Docker build blocked (tool policy: "foreground command appears to start a long-lived server") | Build takes 90-180s which exceeds foreground threshold | Always use `background=true` + `notify_on_complete=true` for docker build. Check result with `process poll` after notification. |
| `docker compose up -d --wait` hangs or fails | Container has no healthcheck defined | Use plain `docker compose up -d` (without `--wait`). App-management will discover the running container on restart. |
| `curl -I` (HEAD) returns 405 on icon.svg or other static routes | FastAPI route handler (`@app.get`) only handles GET, not HEAD | Use `curl -s` (GET) for verification. The CasaOS frontend uses GET, not HEAD, so this is a testing-only issue. |
| `curl` returns nothing (exit code 7) | Container not ready yet | Wait 3-5s after `up -d`, then retry |
| API endpoints return 500 or "Not Found" after adding `StaticFiles` mount | `StaticFiles` was mounted **before** API routes in the file. FastAPI processes mounts in registration order — a mount at `/` before API routes shadows all endpoints. | Move the `app.mount("/", StaticFiles(...))` call to the **end** of the file, AFTER all `@app.get/post/put/delete` route definitions. This ensures API routes have priority over static file serving. |
| `icon.svg` returns 500 even though file exists in project root | Icon file is in the project root but `StaticFiles` only serves from `frontend/` directory | Copy `icon.svg` into `frontend/icon.svg`. The `StaticFiles(directory=str(FRONTEND_DIR))` mount only serves files from within that directory — project-root files are invisible. |
| `delegate_task` with Claude Code times out on frontend creation (600s) | Claude Code gets stuck in loops or hits token limits when writing large single-file SPAs inline | Don't delegate full SPA creation to Claude Code. Write the `index.html` directly using `write_file` then delegate only the build/deploy steps. Claude Code works better for targeted code edits, not massive single-file generation. |
| "Derzeit nicht unterstützt" when clicking app tile | Container registered as `v2app` type without proper `x-casaos` extension. Docker compose generates `com.docker.compose.*` labels that trigger v2app registration, but without an `x-casaos` top-level block the service can't read routing fields. | Option A (simpler): redeploy via `docker run` with only `casaos.*` labels. Option B (full): add proper `x-casaos` block to the compose file with all required fields (hostname, icon, index, port_map, scheme, store_app_id, title). Stop app-management, deploy, restart. See full workflow above. |
| "Derzeit nicht unterstützt" or blank page when clicking app tile over VPN/remote access | **(1)** `hostname` is hardcoded to a LAN IP (e.g. `192.168.178.171`) that is unreachable through a VPN tunnel — the client browser can't resolve the LAN IP. **(2)** `hostname="localhost"` causes the frontend to resolve to the client machine instead of ZimaOS server. **(3)** `hostname="172.17.0.x"` (Docker bridge IP) is only reachable from inside Docker. | Set `hostname=""` (empty string) for universal compatibility — works on LAN, VPN, and remote. Every official CasaOS app (Plex, Immich, Komga, Audiobookshelf) ships with empty hostname. **This is the #1 VPN access bug** — user says "drücke drauf und komme nicht rein, bin über VPN". The JSON `app_type` field is NOT the issue (all apps are `v2app`). Check `hostname` first. Only hardcode the LAN IP in `casaos.icon` if you need the icon to display on the dashboard. |
| Codex CLI / Node.js install fails on ZimaOS | No apt/dnf/apk/yum/pacman on ZimaOS. Read-only `/usr/local`. Only way is manual tarball extract, then npm global installs hit permission errors. **Don't waste time** — if user says "lass das über Codex laufen" on ZimaOS, tell them Codex can't be installed here and offer to do the work directly instead. |\n| Next.js app blocked in iframe (blank page, console CSP error) | `next.config.ts` headers block framing: `X-Frame-Options: DENY` + `frame-ancestors 'none'` | Patch `next.config.ts` security headers: set `X-Frame-Options: ALLOWALL` and `frame-ancestors *`. Rebuild image, redeploy. |
| `ModuleNotFoundError: No module named 'scipy'` in trimesh | ZimaOS has no scipy. `trimesh.fix_normals()`, `split()`, `boolean difference`, and many advanced mesh operations import scipy internally. | Avoid `fix_normals()`, `split()`, and `Mesh.connected_components`. Use basic creation functions (`trimesh.creation.*`). For boolean ops, try `manifold3d` if available. The venv at `/DATA/AppData/hermes/venv` has trimesh+manifold3d but no scipy. |
| `docker inspect` shows empty `Config.Labels` | Container was started without labels | Redeploy via `docker run` with `-l` flags |
| JSON edit reverted after service restart | `zimaos-app-management` overwrites JSON from memory on shutdown | Stop service first (`sudo systemctl stop zimaos-app-management`), edit JSON, then start |
| `title` field shows as dict object, not string | `title` is i18n dict `{"en_US": "AppName"}` | Parse with `.get('en_US', '')`, never cast directly |
| App reappears as `v2app` after cleanup | Service re-discovers compose-managed container with `com.docker.compose.*` labels | Redeploy via `docker run` (step 2 of fix pipeline) — compose labels are sticky |
| After rebuild + gateway restart, user still sees old UI | **Browser cache.** The browser cached the old HTML/CSS/JS and serves it instead of requesting the new version. Compounding factor: CasaOS loads apps in iframes, which makes cache-busting less visible. | Tell user: **Hard refresh** (`Ctrl+Shift+R` / `Cmd+Shift+R`) or open in **private/incognito window**. The container code is correct (verify with `curl localhost:<port>/`), so there's nothing wrong with the build — it's client-side caching. Do NOT rebuild again; it won't help. |
| App UI looks outdated/old after "redeploy" (wrong colors, missing features, old layout) | `docker compose up -d` did NOT rebuild the image. Compose only rebuilds when `--build` is passed or the image tag changed. **Common failure mode:** compose file uses `image: <name>` directive — compose caches the image tag and `up -d` pulls the stale tagged image even after a separate `docker build`. The container runs old code even though a fresh image exists with a different hash. Verify with `docker exec <name> wc -c /app/frontend/index.html` — if byte counts differ from source, the container has the old image. | **Nuclear fix (always works):** (1) `docker compose down`, (2) `docker rmi <image>:latest`, (3) `docker compose up -d --build` (compose detects missing image, builds fresh). Or use explicit rebuild: `DOCKER_CONFIG=/tmp/docker-config docker build --no-cache -t <image> <context>`, then `docker compose down && docker compose up -d`. **DO NOT** rely on `docker compose build --no-cache` alone when the compose file has an `image:` directive — the tagged image reference can persist in compose's project state. The nuclear `docker rmi` forces compose to build from scratch. Always verify with `docker exec <container> wc -c /app/frontend/index.html` after deploy to confirm the byte count matches the source file. |
| `docker compose build` blocked by tool policy (foreground long-lived server) | Build takes 90-180s | See "Docker build blocked" pitfall above — use `background=true` + `notify_on_complete=true`. Combine build+up in one background command: chain `down && build --no-cache && up -d` then `start app-management` and `restart gateway` in a separate foreground command after. |
| New `docker run` container not showing in CasaOS Dashboard | `zimaos-app-management` only scans for new containers on startup, not continuously. A freshly started container appears in the `appgrid` API immediately (as type `container`), but doesn't always register in the `installed` list. | **(1)** Stop app-management (`sudo systemctl stop zimaos-app-management`), **(2)** Remove the container's hash-ID entry from `/etc/casaos/app-management.json` while the service is stopped, **(3)** Start app-management (`sudo systemctl start zimaos-app-management`) — it re-scans Docker on start and discovers the container, **(4)** Restart zimaos-gateway (`sudo systemctl restart zimaos-gateway`) to pick up new routes. Also works: `docker stop <name> && docker start <name> && systemctl restart zimaos-app-management`. The container restart forces fresh label reading, and the app-management restart triggers the scan. |
| VPN access: user connected via VPN can't reach app | `hostname` was set to LAN IP (e.g. `192.168.178.171`) — client browser over VPN can't resolve that. | Set `hostname=""` (empty string) — frontend constructs iframe URL using `window.location.hostname` which is the same host used for the dashboard, so it works regardless of LAN, VPN, or remote access. Deploy via v2app compose file (Pattern B) for reliable VPN routing. All official CasaOS apps use empty hostname. |
| New `docker run` container not showing in CasaOS Dashboard | `zimaos-app-management` only scans for new containers on startup, not continuously. A freshly started container appears in the `appgrid` API immediately (as type `container`), but doesn't always register in the `installed` list. | **(1)** Stop app-management (`sudo systemctl stop zimaos-app-management`), **(2)** Remove the container's hash-ID entry from `/etc/casaos/app-management.json` while the service is stopped, **(3)** Start app-management (`sudo systemctl start zimaos-app-management`) — it re-scans Docker on start and discovers the container, **(4)** Restart zimaos-gateway (`sudo systemctl restart zimaos-gateway`) to pick up new routes. Also works: `docker stop <name> && docker start <name> && systemctl restart zimaos-app-management`. The container restart forces fresh label reading, and the app-management restart triggers the scan. |
| Container appears in Legacy Apps but tile is not clickable (no response or blank page) | zimaos-app-management auto-detected the container and created a bare-bones `container`-type entry in `app-management.json` with only `title`, `image`, `name` (Docker hash), `status`, and `is_uncontrolled`. The entry is MISSING `port`, `scheme`, `icon`, `hostname`, and `index` — so the frontend can't construct a valid iframe URL. CasaOS does NOT automatically read `casaos.*` Docker labels into the JSON entry on detection. | **(1)** Stop app-management: `sudo systemctl stop zimaos-app-management`. **(2)** Edit the JSON: find the entry with `app_type: "container"` and the right image name, then either: (a) set `app_type` to `v2app` and add all routing fields (`port`, `scheme`, `hostname=\"\"`, `icon`, `index`, `store_app_id`, `author_type`), OR (b) remove the auto-entry entirely, restart app-management, and deploy via Pattern B (compose with `x-casaos`). **(3)** Start app-management: `sudo systemctl start zimaos-app-management`. **(4)** Restart gateway: `sudo systemctl restart zimaos-gateway`. **Note:** editing JSON while app-management is running will be overwritten — always stop first. |

## Reference Apps

- **PrintVault** — `/DATA/AppData/hermes/projects/printvault/` — STL library with upload, trimesh analysis, matplotlib thumbnails, tag management. Full blue-glass frontend blueprint at `references/blue-glass-frontend-blueprint.html`.
- **DocMaster** — inspiration for port pattern (3000 + Next.js), file-on-HDD storage, optional Basic Auth

To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'


Das Problem ist klar aus dem Skill: `zimaos-app-management` hat den Container auto-detected, aber nur einen Bare-Minimum-Eintrag erstellt — ohne `port`, `scheme`, `hostname`, `icon`, `index`. Das Frontend kann keine gültige iframe-URL bauen → Tile nicht klickbar. Fix:

Explanation:
The skill explains the issue perfectly. The problem is that `zimaos-app-management` auto-detected the container but created a bare-bones `container`-type entry missing `port`, `scheme`, `hostname`, `icon`, and `index`. The tile shows up but isn't clickable because the frontend can't construct a valid iframe URL.

The fix: I need to stop app-management, add the missing routing fields to the container entry, then restart.

But wait — looking at the current state of the JSON, my previous fix already converted it to v2app. But the auto-detection might have re-created a bare `container` entry. Let me check what's in the file now.

Actually, looking at the flow:
1. I removed old entries + added v2app → restarted gateway
2. CasaOS auto-detected the container and created a bare `container` entry

The approach from the skill: Stop app-management, find the bare container entry with `image: "callkeep"`, AND add the routing fields to it (port, scheme, hostname="", icon, index). OR switch to compose-based deployment with x-casaos.

Let me go with the simpler approach: stop app-management, patch the bare container entry to add routing fields.

Actually, looking more carefully at the skill, it says the container-type entry just needs `port`, `scheme`, `hostname=""`, `icon`, `index` to become clickable. Let me do that.

terminal

sudo systemctl stop zimaos-app-management && echo "stopped"


Output: stopped


Explanation: Let me fix the app-management.json entry. I need to find the auto-detected CallKeep container entry (which has `app_type: "container"` and missing routing fields) and fix it to a proper `v2app` entry with all required fields. The problem is clear: the container auto-detected but the JSON entry lacks port, scheme, hostname, icon, index. So the CasaOS frontend can't build a clickable iframe URL.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)

# Find the auto-detected CallKeep entry (container type, image=callkeep)
for a in apps:
    if a.get('image') == 'callkeep' and a.get('app_type') == 'container':
        print('Found auto-entry:', json.dumps(a, indent=2))
        # Fix to proper v2app with all routing fields
        a['app_type'] = 'v2app'
        a['author_type'] = 'community'
        a['hostname'] = ''
        a['icon'] = 'http://192.168.178.171:3002/icon.svg'
        a['index'] = '/'
        a['port'] = '3002'
        a['scheme'] = 'http'
        a['store_app_id'] = 'callkeep'
        a['name'] = 'callkeep'
        a['title'] = {'custom': 'CallKeep'}
        print('Fixed to:', json.dumps(a, indent=2))
        break

with open('/etc/casaos/app-management.json','w') as f:
    json.dump(apps, f, indent=2)
print('Saved')
"


Output:
Found auto-entry: {
  "app_type": "container",
  "image": "callkeep",
  "is_uncontrolled": false,
  "name": "dc504d35972239642cf9bd2495025fe7da77f02c9beb33291d3d5b677007dc9a",
  "status": "running",
  "title": {
    "en_US": "CallKeep"
  }
}
Fixed to: {
  "app_type": "v2app",
  "image": "callkeep",
  "is_uncontrolled": false,
  "name": "callkeep",
  "status": "running",
  "title": {
    "custom": "CallKeep"
  },
  "author_type": "community",
  "hostname": "",
  "icon": "http://192.168.178.171:3002/icon.svg",
  "index": "/",
  "port": "3002",
  "scheme": "http",
  "store_app_id": "callkeep"
}
Saved


Explanation:
The user said "Geht immer noch nicht" - the app is still not clickable in CasaOS. Let me look at the skill more carefully.

The skill's pitfall table says:

"Container appears in Legacy Apps but tile is not clickable (no response or blank page)" - cause: zimaos-app-management auto-detected the container and created a bare-bones `container`-type entry missing `port`, `scheme`, `icon`, `hostname`, `index`. The fix is to stop app-management, edit JSON, start app-management, restart gateway.

I just did exactly that! But wait - there's another issue. The skill says that after cleaning up, the app-management service might re-discover the container on restart and recreate a bare-bones entry. The nuclear fix from the skill is to deploy via Pattern B (compose with x-casaos block) to get a proper v2app registration that survives.

Let me try deploying via the compose pattern instead. I'll create a docker-compose.yml with x-casaos block in /var/lib/casaos/apps/CallKeep/.

sudo systemctl start zimaos-app-management && sleep 5 && sudo systemctl restart zimaos-gateway && echo "Done"


Output: Done


Das Problem war: Der auto-detected Eintrag hatte nur `title`, `image`, und `name` (Docker-Hash) — keine `port`/`scheme`/`hostname`/`icon`/`index`. Ohne diese Felder weiß das Frontend nicht, wohin es den iframe routen soll, und der Klick tut einfach nichts.

Hab den Eintrag direkt gefixt: `v2app` mit allen Routing-Feldern. Dazu muss ich noch das CSP-Middleware in main.py einbauen, damit CasaOS die App im iframe laden darf:

Explanation: The key fix: the auto-detected `container`-type entry was missing `port`, `scheme`, `hostname`, `icon`, `index` — those are the fields CasaOS uses to construct the iframe URL. Without them, the tile has nowhere to link to, so clicking does nothing. I upgraded it to `v2app` with all routing fields. The skill also notes a CSP middleware that allows iframe embedding — I should add that to main.py too.


     1|"""
     2|CallKeep — Dein Beziehungs-Tracker
     3|FastAPI Backend + SQLite + Static Frontend
     4|"""
     5|import os
     6|import sqlite3
     7|import json
     8|from datetime import datetime, date, timedelta
     9|from pathlib import Path
    10|from contextlib import contextmanager
    11|from fastapi import FastAPI, HTTPException, Query
    12|from fastapi.responses import FileResponse, Response
    13|from fastapi.middleware.cors import CORSMiddleware
    14|from pydantic import BaseModel
    15|from typing import Optional
    16|
    17|FRONTEND_DIR = Path(__file__).parent / "frontend"
    18|DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent))
    19|DB_PATH = DATA_DIR / "callkeep.db"
    20|
    21|from fastapi.staticfiles import StaticFiles
    22|
    23|app = FastAPI(title="CallKeep")
    24|app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
    25|
    26|

Use offset=26 to continue reading (showing 1-25 of 338 lines)


Explanation:
The user asked "Geht immer noch nicht" about the CasaOS tile not being clickable. I've now applied the fix from the skill — stopped app-management, converted the auto-detected container entry to v2app with all routing fields. But I also need to add the CSP iframe middleware to main.py so the app allows being loaded in an iframe. Let me also add that, then rebuild + redeploy.

The skill says I need both:
1. CSP middleware in the app (Layer 1)
2. Proper v2app registration with routing fields (Layer 2)

I've done Layer 2. Still need Layer 1. Then rebuild + redeploy.

patch

from fastapi import Request

app = FastAPI(title="CallKeep")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

@app.middleware("http")
async def allow_iframe(request: Request, call_next):
    """Allow iframe embedding for CasaOS dashboard."""
    response = await call_next(request)
    response.headers["X-Frame-Options"] = "ALLOWALL"
    response.headers["Content-Security-Policy"] = "frame-ancestors 'self' http://*:* https://*:*;"
    return response


--- a//media/HDD_1TB/CallKeep/main.py
+++ b//media/HDD_1TB/CallKeep/main.py
@@ -20,8 +20,18 @@

 from fastapi.staticfiles import StaticFiles

+from fastapi import Request
+
 app = FastAPI(title="CallKeep")
 app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
+
+@app.middleware("http")
+async def allow_iframe(request: Request, call_next):
+    """Allow iframe embedding for CasaOS dashboard."""
+    response = await call_next(request)
+    response.headers["X-Frame-Options"] = "ALLOWALL"
+    response.headers["Content-Security-Policy"] = "frame-ancestors 'self' http://*:* https://*:*;"
+    return response

 # ── Database ──────────────────────────────────────────────────────
 @contextmanager

/media/HDD_1TB/CallKeep/main.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


Jetzt rebuild + redeploy mit dem CSP-Fix:

Explanation:
The CSP middleware is added. Now I need to rebuild the Docker image and redeploy, since the container is running the old code without the iframe middleware.

Let me rebuild and redeploy.

terminal

sudo docker stop CallKeep 2>/dev/null; sudo docker rm CallKeep 2>/dev/null; sudo docker rmi callkeep 2>/dev/null; DOCKER_CONFIG=/tmp/docker-config docker build --no-cache -t callkeep /media/HDD_1TB/CallKeep/ 2>&1 | tail -5


Output:
CallKeep
CallKeep
Untagged: callkeep:latest
Deleted: sha256:d968675944f10f23764bd117fc665b8a34ab008e19a1ae5d4edcd4a7190f83ba
#10 exporting layers
#10 exporting layers 0.7s done
#10 writing image sha256:0da087889f66eb4574b42321628bdc3101eec14b6cbc42604fd6d1021d730933 done
#10 naming to docker.io/library/callkeep done
#10 DONE 0.7s


Explanation: The user asked about insurance coverage in a document from 2025-02-11 (polizze = insurance policy). Let me extract the text from the PDF and analyze the coverage sections.

sudo docker run -d --name CallKeep --network bridge --restart unless-stopped -p 3002:3002 -v /media/HDD_1TB/CallKeep:/data -e PORT=3002 -e DATA_DIR=/data -l casaos.title="CallKeep" -l casaos.description="Beziehungs-Tracker" -l casaos.icon="http://192.168.178.171:3002/icon.svg" -l casaos.scheme="http" -l casaos.hostname="" -l casaos.port="3002" -l casaos.index="/" -l casaos.category="Utilities" callkeep && sleep 2 && curl -s -o /dev/null -w "%{http_code}" http://localhost:3002/ && echo ""


Output:
0ddfe67e57d6b19e1acdab989c6d82165ecc03815ee3f4a6fbc9e8677ede9bfd
200


Explanation: Der Container ist jetzt neu mit der Container-ID `0ddfe67e57d6`. Der alte JSON-Eintrag verweist aber noch auf `dc504d359722` — das ist die alte Container-ID. Ich muss app-management stoppen, den alten Eintrag ersetzen, und neu starten.

sudo systemctl stop zimaos-app-management && sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)

# Remove ALL old CallKeep entries
before = len(apps)
apps = [a for a in apps if 'CallKeep' not in str(a.get('title','')) and a.get('image','') != 'callkeep']
print(f'Removed {before - len(apps)} entries')

# Add fresh entry with NEW container ID
apps.append({
    'app_type': 'v2app',
    'author_type': 'community',
    'hostname': '',
    'icon': 'http://192.168.178.171:3002/icon.svg',
    'image': 'callkeep',
    'index': '/',
    'is_uncontrolled': False,
    'name': 'callkeep',
    'port': '3002',
    'scheme': 'http',
    'status': 'running',
    'store_app_id': 'callkeep',
    'title': {'custom': 'CallKeep'}
})

with open('/etc/casaos/app-management.json','w') as f:
    json.dump(apps, f, indent=2)
print(f'Added. {len(apps)} apps total.')
" && sudo systemctl start zimaos-app-management && sleep 5 && sudo systemctl restart zimaos-gateway && echo "All done"


Output:
Removed 0 entries
Added. 13 apps total.
All done


Explanation:
Wait, "Removed 0 entries" means the old entry wasn't found. Let me check what the previous fix did — I stopped app-management, fixed the entry to v2app, and started it again. The entry was already fixed in-place. But I redeployed with a NEW container ID (0ddfe67e...). The old entry had `name: callkeep`, so it should still match. Let me verify the current state.

Actually wait — the Python script removed 0 entries but it searched for 'callkeep' in title. The entry might have had `title: {'custom': 'CallKeep'}` which should match. But it said "Removed 0" — that's odd. Let me check.

But actually I said "removed 0 entries" then "added. 13 apps total" — which means the old entry wasn't there? Wait, but the previous fix changed the auto-entry from container to v2app with title `{'custom': 'CallKeep'}`. The `'CallKeep' not in str({'custom': 'CallKeep'})` would be False (because 'CallKeep' IS in the string representation), so it wouldn't be filtered. So it should have been removed.

Oh wait — `Removed 0 entries` means the filter didn't catch it. Let me check: `str(a.get('title',''))` for `{'custom': 'CallKeep'}` would be `"{'custom': 'CallKeep'}"` — which DOES contain 'CallKeep'. So why wasn't it removed?

Hmm, maybe the previous restart of app-management overwrote the JSON from memory? That could happen if the app-management service writes its in-memory state on shutdown, losing my edit. The skill says "Stop app-management BEFORE editing the JSON — the service overwrites it from memory on shutdown." But I did stop it before editing.

Let me just check the current state and fix if needed. Also, I should verify the container is actually working and accessible.

Actually let me just stop overthinking this. The key steps are:
1. The container is running with the correct code (CSP middleware)
2. The `app-management.json` entry should be correct now
3. Gateway was restarted

Let me verify and then check if it works. If there's an old entry still there, it's the auto-detected `container` type which would lack routing info — but I just deployed a new container with new hash ID, so app-management might have auto-detected that too.

I should verify the JSON and clean up any stale entries.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)
for a in apps:
    t = a.get('title','')
    if 'CallKeep' in str(t) or a.get('image','') == 'callkeep':
        print(f'type={a[\"app_type\"]} name={a[\"name\"]} port={a.get(\"port\",\"MISSING\")} title={json.dumps(t)}')
"


Output: type=v2app name=callkeep port=3002 title={"custom": "CallKeep"}


Explanation: The skill warned that zimaos-app-management auto-creates container-type entries on startup. Let me check if it re-created one alongside my v2app entry.

sudo python3 -c "
import json
with open('/etc/casaos/app-management.json') as f:
    apps = json.load(f)
for a in apps:
    t = str(a.get('title',''))
    if 'CallKeep' in t or a.get('image','') == 'callkeep':
        print(json.dumps(a, indent=2, ensure_ascii=False))
        print('===')

# Also check: any container-type entry with callkeep image
for a in apps:
    if a.get('app_type') == 'container' and 'callkeep' in str(a.get('image','')).lower():
        print('AUTO-ENTRY:', json.dumps(a, indent=2))
"


Output:
{
  "app_type": "v2app",
  "author_type": "community",
  "hostname": "",
  "icon": "http://192.168.178.171:3002/icon.svg",
  "image": "callkeep",
  "index": "/",
  "is_uncontrolled": false,
  "name": "callkeep",
  "port": "3002",
  "scheme": "http",
  "status": "running",
  "store_app_id": "callkeep",
  "title": {
    "custom": "CallKeep"
  }
}
===


Explanation:
The user keeps saying the CallKeep tile in CasaOS isn't clickable. Let me trace through exactly what's happening. The entry looks correct in app-management.json - it's v2app with all fields. But the user says it's in "Legacy App" section and not clickable.

Wait - looking at the pitfall in the zimaos-web-app skill more carefully:

> Container appears in Legacy Apps but tile is not clickable (no response or blank page) | zimaos-app-management auto-detected the container and created a bare-bones container-type entry... The entry is MISSING port, scheme, icon, hostname, and index — so the frontend can't construct a valid iframe URL.

But I've already fixed that - the entry now has all those fields. And there's no duplicate container entry either.

However, I need to check: is zimaos-app-management actually regenerating the entry? When it starts, it scans Docker. The current container ID is `0ddfe67e57d6`. Let me check if app-management has created a NEW auto-entry with this container ID, overriding my manual one, or creating a duplicate.

Looking at my check - only ONE entry exists with `image: callkeep` and it's the v2app one I created. No container-type auto-entry.

But wait - maybe the issue is that the CasaOS frontend (in the browser) has cached the old JSON state. And the app-management might regenerate the JSON on restart, creating a fresh auto-entry. Let me check after the restart...

Actually, I already checked after the restart and only found one entry. Let me think about what else could be wrong.

The CSP middleware I added is correct and should allow iframe embedding. But it needs to actually be in the RUNNING container. I rebuilt but did I actually verify the new container has the CSP headers?

Let me also consider: maybe the issue is that `zimaos-app-management` overwrites my entry when it starts. The pitfall says: "Manual edits to `/etc/casaos/app-management.json` are reverted by the service on restart because it regenerates the JSON from in-memory state."

So even though I stopped it, edited, and started - on the NEXT restart (which I did with `sudo systemctl restart zimaos-gateway`), app-management might be regenerating. But I didn't restart app-management again, only gateway.

Actually wait - I DID restart app-management (stop, edit, start). The concern is whether it regenerated. Let me verify directly.

Let me check: is the current running container `0ddfe67e57d6`? And is the app-management.json entry pointing to the right thing? And most importantly - do the CSP headers work?

Let me curl with headers to check CSP:

# Prüfe ob der Container läuft und CSP-Header korrekt sind
sudo docker ps --filter name=CallKeep --format '{{.ID}} {{.Status}}' && echo "---" && curl -sI http://localhost:3002/ 2>&1 | grep -i -E 'frame|csp|x-frame' && echo "---" && curl -s http://localhost:3002/api/dashboard


Output:
0ddfe67e57d6 Up 56 seconds
---
x-frame-options: ALLOWALL
content-security-policy: frame-ancestors 'self' http://*:* https://*:*;
---
[]


Jetzt ist alles korrekt:

- **CSP-Header** gesetzt: `X-Frame-Options: ALLOWALL` + `frame-ancestors *` → erlaubt iframe-Einbettung durch CasaOS
- **app-management.json** Eintrag: `v2app` mit `port: 3002`, `scheme: http`, `hostname: ""`, `icon`, `index`
- **Keine Duplikate** mehr, keine Auto-Entries

Container 0ddfe67e läuft, Gateway neu gestartet. Jetzt sollte der Klick funktionieren. Probier mal!

Explanation:
Ich sehe dass `curl -sI` (HEAD) funktioniert und die CSP-Header korrekt gesetzt werden. Der Eintrag in `app-management.json` ist jetzt korrekt: `v2app`, port 3002, scheme http, hostname "", icon gesetzt.

Es gibt aber noch ein potenzielles Problem: Der `curl -I` (HEAD-Request) für icon.svg gab vorher 405. Aber das habe ich nicht neu getestet.

Die entscheidenden Dinge sind jetzt korrekt:
1. CSP-Middleware erlaubt iframe (`X-Frame-Options: ALLOWALL`, `frame-ancestors *`)
2. `app-management.json` hat korrekte `v2app` mit allen Feldern
3. Gateway neugestartet

Das sollte jetzt funktionieren. Ich fasse zusammen was gemacht wurde.


Nein, Callkeep steht immer noch in Legacy app und wenn ich es öffne kommt das

Importieren
App-Name *
CallKeep
Icon URL

Ihre eigene Icon-URL
Web UI

http://
192.168.178.171
Ports
/index.html Optional


Der Import-Dialog bedeutet: CasaOS erkennt den Container, aber weiß nicht wie es ihn routen soll. Der manuelle JSON-Edit reicht nicht — ich muss über **Pattern B** (compose mit `x-casaos`) deployen.

Explanation:
Der User zeigt mir den Import-Dialog von CasaOS. Das bedeutet, dass CasaOS CallKeep NICHT als installierte App erkennt, sondern es als nicht erkannten Container in "Legacy Apps" anzeigt und beim Draufklicken den Import-Dialog öffnet — als ob man eine neue App manuell importieren müsste.

Das Problem ist nicht der JSON-Eintrag — das Problem ist grundlegender. Der `v2app`-Eintrag, den ich manuell hinzugefügt habe, wird von `zimaos-app-management` wahrscheinlich nicht als gültig akzeptiert, weil der Container nicht über den CasaOS-App-Store oder das offizielle App-Management-System installiert wurde.

Laut dem Skill gibt es zwei gültige Deployment-Patterns:
- **Pattern A**: `docker run` mit `casaos.*` Labels → Auto-Discovery als `container`-Typ
- **Pattern B**: compose file mit `x-casaos` Block → `v2app`-Typ

Ich hab Pattern A versucht (docker run mit Labels), aber das erstellt nur einen `container`-Typ ohne Routing-Felder. Ich muss Pattern B verwenden: compose file mit `x-casaos` in `/var/lib/casaos/apps/callkeep/`.

Der Import-Dialog, den der User sieht, ist CasaOS's Weg zu sagen: "Ich hab diesen Container gefunden, aber ich weiß nicht, wie ich ihn routen soll. Importier ihn manuell."

Lösung: Ich muss die App über Pattern B deployen — compose file mit `x-casaos` Block. Dazu muss ich:
1. `docker-compose.yml` in `/var/lib/casaos/apps/callkeep/` erstellen
2. Source-Dateien dorthin kopieren
3. app-management stoppen, container stoppen/löschen, compose build + up
4. app-management starten, gateway neustarten

Laut Skill muss der compose file `network_mode: bridge` haben (nicht default isolated network).

Wichtig: `DOCKER_CONFIG=/tmp/docker-config` für compose build auf ZimaOS.

Lass mich das jetzt umsetzen.

sudo docker stop CallKeep 2>/dev/null; sudo docker rm CallKeep 2>/dev/null; echo "cleaned"


Output:
CallKeep
CallKeep
cleaned


Explanation:
Let me analyze the situation carefully. The user says CallKeep still appears in "Legacy Apps" and when they click it, they get an import dialog asking for:
- App Name: CallKeep
- Icon URL
- Web UI URL
- Ports
- Index

This means CasaOS doesn't properly recognize the app — it sees the container but can't route to it. The manual JSON edit to `/etc/casaos/app-management.json` didn't stick or wasn't sufficient.

According to the zimaos-web-app skill, Pattern B (compose with `x-casaos` block) is the reliable way. I need to:

1. Create `/var/lib/casaos/apps/callkeep/docker-compose.yml` with proper `x-casaos` block
2. Copy source files there
3. Stop app-management
4. Build with compose
5. Clean up old entries in app-management.json
6. Start app-management + restart gateway

Let me follow the Pattern B deployment steps exactly.

sudo mkdir -p /var/lib/casaos/apps/callkeep && sudo cp /media/HDD_1TB/CallKeep/main.py /var/lib/casaos/apps/callkeep/ && sudo cp -r /media/HDD_1TB/CallKeep/frontend/ /var/lib/casaos/apps/callkeep/ && echo "copied"


Output: copied