# skill: fiskmas

> Use Fiskmås, an MCP-native Docker app host, to deploy a containerized HTTP app. Use when the user wants to host, deploy, run, stop, or debug a Docker app reachable over HTTPS via an MCP client.

# Fiskmås

Fiskmås is an MCP-native Docker app host. A human talks to their existing AI
client (Claude, ChatGPT, Cursor); the client talks to Fiskmås over MCP. Hosting
is the durable piece — there is no web editor to build.

- MCP (Streamable HTTP): https://mcp.fiskmas.dev/mcp
- Registry: registry.fiskmas.dev (accepts only `registry.fiskmas.dev/<owner>/<slug>/...`)
- Apps: https://<slug>-app.fiskmas.dev
- Docs index: https://fiskmas.dev/llms.txt
- Landing page (for humans): https://fiskmas.dev

## MCP connection (you configure it)

Prefer Fiskmås MCP tools. Avoid curl/REST against fiskmas.dev as a workaround.

If Fiskmås MCP tools are missing in this session, **configure the MCP server
yourself** in the client, wire auth from env `FISKMAS_TOKEN`, reload MCP or
start a new chat if tools do not appear, then continue.

- URL (Streamable HTTP): `https://mcp.fiskmas.dev/mcp`
- Auth header: `Authorization: Bearer <token>` (from `FISKMAS_TOKEN`)

Grok — two surfaces:

- **Grok Build (CLI):** install the `fiskmas` plugin from the xAI plugin
  marketplace (`grok plugin install fiskmas --trust`; source:
  https://github.com/pefman/fiskmas-grok-plugin). The plugin ships this skill
  plus the MCP pointer — the human side only needs `FISKMAS_TOKEN` in the
  environment.
- **Grok (web/app):** add a custom MCP connector pointing at the URL above.
  Grok's connector dialog is OAuth-only (no Bearer field) and Fiskmås serves
  no OAuth discovery, so in chat only the public onboarding tools work
  (`create_account`, `account_status`) — use Grok Build or another MCP client
  for the full toolset.

Claude Code — project `.mcp.json`, or
`claude mcp add --transport http fiskmas https://mcp.fiskmas.dev/mcp --header "Authorization: Bearer ${FISKMAS_TOKEN}"`:

```json
{
  "mcpServers": {
    "fiskmas": {
      "type": "http",
      "url": "https://mcp.fiskmas.dev/mcp",
      "headers": { "Authorization": "Bearer ${FISKMAS_TOKEN}" }
    }
  }
}
```

Cursor — `~/.cursor/mcp.json` or `.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "fiskmas": {
      "url": "https://mcp.fiskmas.dev/mcp",
      "headers": { "Authorization": "Bearer ${env:FISKMAS_TOKEN}" }
    }
  }
}
```

Codex — `~/.codex/config.toml`:

```toml
[mcp_servers.fiskmas]
url = "https://mcp.fiskmas.dev/mcp"
http_headers = { Authorization = "Bearer ${FISKMAS_TOKEN}" }
```

OpenCode — `opencode.json`:

```json
{
  "mcp": {
    "fiskmas": {
      "type": "remote",
      "url": "https://mcp.fiskmas.dev/mcp",
      "enabled": true,
      "headers": { "Authorization": "Bearer {env:FISKMAS_TOKEN}" }
    }
  }
}
```

Manual MCP clients can use the same client-neutral shape. Set `FISKMAS_TOKEN`
in the environment, then adapt the config filename and environment-variable
syntax to the client on Linux, macOS, or Windows:

```json
{
  "mcp": {
    "fiskmas": {
      "type": "remote",
      "url": "https://mcp.fiskmas.dev/mcp",
      "enabled": true,
      "headers": {
        "Authorization": "Bearer {env:FISKMAS_TOKEN}"
      }
    }
  }
}
```

## Onboarding

First run (no API token yet):

1. Ask the human for an email address.
2. Call `create_account` with that **email** (required). This creates a
   **pending** account and emails a verify link — it does **not** return a token.
3. Tell the human to open the email, click **Verify & get API token**, and
   paste the one-time token back to you (shown once on https://fiskmas.dev/verify/…).
4. **You** store it as `FISKMAS_TOKEN` (shell env and/or MCP Authorization
   header), then call `whoami`. Same token for
   `docker login -u "$FISKMAS_TOKEN" registry.fiskmas.dev`.
5. Poll `account_status` with the email until `account_status` is `active`
   if needed.

If you already have a Bearer token configured, call `whoami` and skip
onboarding. (Humans can also `POST https://fiskmas.dev/api/register` with
`{"email":"..."}` — same pending → verify email flow.)

## Token recovery (lost or dead token)

When `whoami` fails with `invalid or expired token`, `token revoked or
replaced …`, `account is 'pending', not active …` (or `missing
Authorization header`) and the token cannot be recovered from the human's
notes:

1. Confirm the registered email with the human first ("Is that
   `<email>`?") — never send a reissue to a guessed address.
2. Call `reissue_token` with that email. Fiskmås emails a one-time reissue
   link (valid 30 minutes, single use). No token ever reaches you — the
   mailbox is the identity anchor.
3. Tell the human: open the email, click **Reissue & get new API token**, and
   paste the NEW token back to you (shown once on the verify page). The old
   token(s) are revoked the moment the link is clicked.
4. Store it as `FISKMAS_TOKEN` / MCP `Authorization: Bearer <new-token>`
   (and `docker login -u <new-token> registry.fiskmas.dev`), then call
   `whoami` to confirm.
5. If the account is still `pending`, onboarding was never finished — re-send
   the original link with `create_account` instead.
6. Rate limits apply (a few reissue emails per hour, ~10 per day, per
   email): if one is refused, wait the suggested `~Ns` and retry, or
   re-check the email address.

**A token you swapped mid-session is not enough on its own.** MCP clients bake
the `Authorization` header when the connection is configured; the server is
stateless and reads it per request. A new token pasted into the client's config
takes effect only after the MCP connection is reloaded — restart the MCP server
entry, or start a new chat. The 401 text says which case you are in:

- `token revoked or replaced …` — the client is still sending an older token
  (a reissue revokes every older one). Reload the connection.
- `account is 'pending', not active …` — the token is real; onboarding was
  never finished. Poll `account_status`, or re-send the link with
  `create_account`.
- `invalid or expired token` — Fiskmås never issued that token. Re-check what
  was pasted, then reissue.

`reissue_token` never creates an account and never returns a token to the
client, so a misconfigured (or prompt-injected) client can at most trigger
rate-limited emails — it cannot rotate or drain an account.

## Tools

All tools talk to the same control-plane API over the MCP URL.

- `whoami` — identity of the current token.
- `create_account` — start onboarding (email required); sends verify mail; no token returned.
- `account_status` — poll pending/active for an email while waiting on human verify.
- `reissue_token` — token recovery (public, no auth): lost/rotated/dead token → emails a one-time reissue link to the registered address; the human clicks it and pastes the NEW one-time token back; old tokens are revoked immediately.
- `delete_account` — tear down all projects (containers/routes/DB) then delete the account.
- `create_project` — create a project, reserve `<slug>-app.fiskmas.dev`.
  Until the first `deploy_project`, that hostname shows a Fiskmås placeholder
  page (status-aware) — not an error, and not broken DNS.
- `list_projects` / `get_project` — list or fetch a project by id or slug.
- `delete_project` — stop and delete a project (frees the slug after quarantine).
- `set_env` / `list_env` — set env (applies on the next deploy; a restart does not re-apply env) or list keys with masked values.
- `deploy_project` — pull an image by digest and run one container.
- `start_project` / `stop_project` / `restart_project` — control the container.
- `get_status` / `get_logs` — status/url/last error and recent log lines (`tail` lines or a `since` seconds window; logs are live pod streams, not stored history).
- `get_stats` — account-wide or per-project stats: live CPU/mem vs the plan caps and deploy history; the account rollup reports the plan. With `project` + `since` (seconds, plan-capped) it also returns `resources.history` — the project's CPU/mem time series over that window (sampled every ~30 s on the k3s platform; the field is simply absent where there is no metrics history).
- `send_feedback` — report a problem (it broke, you got stuck, or the instructions were unclear) so the platform can be fixed; do not send praise.
- `list_skills` / `get_skill` — return the platform skill bytes.

1. `whoami` — if unauthorized, run onboarding (`create_account` → human verify → Bearer token).
2. `create_project` — pick a slug matching `^[a-z0-9][a-z0-9-]{1,48}$` (not
   reserved). Reserves `<slug>-app.fiskmas.dev`.
3. Build and push the image locally, then `docker login registry.fiskmas.dev`
   and `docker push registry.fiskmas.dev/<owner>/<slug>:<tag>`. Resolve the tag
   to a digest (`sha256:...`).
4. `deploy_project` with the digest-pinned reference.
5. `get_status` — confirm `running`/`healthy` and read the URL. (Before the
   first deploy the URL shows a Fiskmås placeholder page — that is expected,
   not a failure.)
6. `get_logs` if the app is not healthy.
7. `stop_project` / `restart_project` / `delete_project` as needed.

## Feedback

`send_feedback` is for problems only — do not send praise. When something
didn't work, report it: `kind=bug` when a tool errored or misbehaved,
`kind=suggestion` when the instructions or a skill were unclear or could be
better, `kind=other` for anything else (e.g. you got stuck and couldn't
proceed). Name the `tool` and, if relevant, the `project`. At most one
feedback call per session — don't spam.

## Quotas and limits

Plans (owner-set, 2026-09-13): **Free** (always on for one project, sleeps
after 24 h idle) and **Plus, $9.99/mo** (5 always-on projects, bigger caps,
longer log windows). `whoami` and the account rollup in `get_stats` report
the active plan and its limits.

|                    | Free                     | Plus ($9.99/mo)          |
|--------------------|--------------------------|--------------------------|
| Running projects   | 1                        | 5                        |
| Per-project cap    | 256 MB / 0.2 CPU         | 512 MB / 1 CPU           |
| Log window         | last 24 h, 200-line tail | last 7 d, 2000-line tail |
| Project database   | ~256 MB fair use         | ~1 GB fair use           |
| MCP tools          | all                      | all                      |

- **Free projects sleep** after 24 h of no activity (no agent calls and no
  web requests to their URL). Sleep is a cost saver only: the workload
  hibernates (no CPU/memory billed) but the project, its URL, its data and
  its database row stay intact. A request to the URL wakes it
  automatically (a short "waking…" page appears for a few seconds).
- `get_logs` is a **live window** into the running container, not a stored
  log: `tail` (lines, default 200, plan-capped) or `since` (seconds,
  plan-capped: 86400 free / 604800 plus — when both are given, `since`
  wins). Logs of a currently-asleep project are unavailable until it wakes.
- **Project databases are fair-use**: the size above is a guide, not an
  hard quota; a project that grows far past it may be paused.
- **Deploys are rate-limited to 1 per 5 minutes per account.** A rejected
  `deploy_project` reply says when to retry (`retry in ~Ns`). While you wait,
  `restart_project` is free and unthrottled: on production it recreates the
  pod from the stored workload (re-running the injected-DB resolver gate and
  the app's storage probes) and waits for readiness — a failed start returns a
  named error, and a plain retry usually clears a transient DNS drop. It
  re-runs the *current* digest, so use it for crashes — not for picking up a
  new image or for `set_env` changes (those need a redeploy).
- One slug = one container. No replicas, no private networking between apps.
- linux/amd64 only, HTTP only. **The app filesystem is read-only** — writing
  anywhere except `/tmp` fails with `EROFS`; `/tmp` is a 64 Mi RAM scratch disk
  (gone on restart, counts against the memory cap). There are no volumes:
  persist data in the shared MariaDB (`DATABASE_URL` / `MYSQL_*` env, injected
  on `create_project`). An app that writes files at runtime will crash — fix
  the app or move that data to SQL.
- Apps are isolated from the platform control plane (per-account namespace in
  production — with a plan-sized ResourceQuota on the whole account and a
  NetworkPolicy that blocks pod-to-pod and control-plane access; see
  `docs/network-policy.md` — plus a dedicated Docker network in local dev).
  The injected DB hostname is the right one for the app's network — on
  production it is the cluster-FQDN `shared-mariadb.platform.svc.cluster.local`;
  never hardcode a host or `set_env` over the injected `DATABASE_URL`/`MYSQL_*`.

## Plans and billing

- **Free**: 1 running project, 24 h idle sleep, as above. No payment.
- **Plus ($9.99/mo, Stripe, no trial)**: 5 always-on projects, 512 MB /
  1 CPU per project, 7-day log windows, 1 GB fair-use database. Cancel
  anytime → the account returns to Free (projects that exceed the Free
  limits are hibernated/stopped; nothing is deleted).
- **Purchasing is a human step.** Upgrading runs through Stripe Checkout:
  the human opens the upgrade button on the plans section of
  `https://fiskmas.dev` (or the `upgrade_url` `whoami` reports), enters
  their Fiskmås API token, pays on Stripe's hosted page. The platform's
  billing webhook flips the account to Plus automatically after payment —
  the agent never initiates or sees payment; `whoami`/`get_stats` simply
  start reporting `plus`.
- If a tool is rejected because of a plan limit (quota, cap, log window),
  relay the error text to the human: it names the plan, the limit, the
  price and the upgrade page. Do not retry the same call; the limit will
  not change itself.

## Docker contract (see fiskmas-docker)

The container must listen on `0.0.0.0:$PORT` (PORT=8080), serve HTTP, expose a
health path (default `/health` or `/`), be non-root if possible, and avoid
privileged mode, docker.sock, and extra published ports. If it does not bind
8080 or the health check fails, status is `failed`/`unhealthy`, not running.

## Debugging

See the `fiskmas-debug` skill for 502s, crash loops, health failures, resource
pressure (`get_stats`), and how to read logs and choose restart vs redeploy.

# skill: fiskmas-debug

> Debug a Fiskmås app that is unhealthy. Use when an app returns 502, is in a crash loop, fails its health check, or when deciding between restart and redeploy.

# Fiskmås debugging

Start with `get_status` (status, url, digest, last error), then `get_logs`, and
`get_stats` when you suspect resource pressure.

## 502 Bad Gateway

The proxy reached the host but the app was not serving.
- `get_status` → if `stopped`, the workload died; `start_project` or redeploy.
- `get_logs` → check the app bound to `0.0.0.0:$PORT` (not `127.0.0.1`)
  and that `$PORT` is `8080`. A server listening on the wrong address fails
  silently behind the proxy.
- Restart the workload, then redeploy if the binary/config is wrong.

## Crash loop (status flips running → failed)

- `get_logs` → look for the fatal line (missing env, bad port, OOM).
- OOM? The app exceeded the memory cap (256 MB Free / 512 MB Plus). Reduce usage or fix
  the leak.
- Missing env? `set_env` the required vars, then `deploy_project`: a restart
  recreates the pod from the stored workload (local dev reuses the same
  container) and does not re-apply env.

## Health check fails

- Confirm the health path exists (default `/health` or `/`).
- Confirm it responds on `127.0.0.1:$PORT` *inside* the app.
- A deploy is only cut over when healthy; on failure the previous workload is
  kept (if any) and the deploy is marked `failed`.

## Resource pressure (memory / CPU)

`get_stats` shows live CPU and memory against the plan caps (256 MB / 0.2 CPU free, 512 MB / 1 CPU plus)
and deploy history. Pass a `project` for a single project's view.

- Memory near the plan cap (256 MB free / 512 MB plus) → OOM risk; the container gets killed and the
  project is marked `failed`. Reduce usage (caches, batch sizes) or fix the
  leak, then `restart_project`.
- CPU pinned at the plan CPU cap (0.2 CPU free / 1 CPU plus) → the app is throttled; optimize hot paths.

## restart vs redeploy

- **restart** — cheap; recreates the pod from the stored workload with the
  same image and env. Use for transient crashes and for a transient DB-DNS
  drop: on production the new pod re-runs the `dns-db-gate` resolver probe
  and the app's storage probes, and `restart_project` now waits for the pod
  to become ready — a failed start returns a named error (e.g. "injected
  database host … is not resolvable"), so a second `restart_project` is the
  cheap retry. It does NOT re-apply env — after any `set_env`, redeploy
  instead. `restart_project` is NOT rate-limited (deploys are: 1 per 5
  minutes per account), so when a deploy fails with a `deploy rate limit`
  error, prefer it while you wait.
- **redeploy** — pulls the new digest (or re-applies the current one) and runs
  the workload with the stored env, health-gated. Use after pushing a new image
  AND after `set_env` on production.

## Database unreachable (ENOTFOUND on the MariaDB host)

If logs show `ENOTFOUND` for the MariaDB host (e.g. `shared-mariadb`) even
though `get_status` says running/healthy, the workload is older than the
platform's current DB hostname: the app's readiness probe is its own `/health`
and does not check the DB. The stored env now carries the correct
hostname (the platform migrates it on boot), so `deploy_project` to re-create
the workload with the current env. Do not use `restart_project` for this on
production — it reuses the stored workload's old env. Never `set_env` a custom
`DATABASE_URL`/`MYSQL_*` over the injected ones; that replaces the whole env
map and drops the provisioned credentials.

## URL shows a Fiskmås placeholder page

Not an error: before the first `deploy_project`, `<slug>-app.fiskmas.dev`
serves a small status-aware placeholder from the control plane, so the host
never 404s. The page states the project status. To make it serve the app,
push the image and `deploy_project`; the URL flips to the app once the deploy
is healthy. Only debug further if the page shows `failed`/`stopped` or the app
still placeholders after a healthy deploy (then check the per-deploy routing
with `get_status` → url).

## Escalate (feedback loop)

If you exhaust the above and still can't explain it, and you believe the
problem is the platform (not your app) — or the docs/skills were unclear and
led you astray — call `send_feedback` with `kind=bug` (or `kind=suggestion`
for unclear docs) and name the `tool` and `project` in the message. Do not
use it for anything but problems.

## Quick checklist

1. `get_status` → read status + last error.
2. `get_logs --tail 200` → find the fatal line.
3. `get_stats` → is it resource pressure (mem near the plan cap, CPU at the plan cap)?
4. Fix port (`0.0.0.0:8080`), env, memory, or queries.
5. `restart_project` for crashes; `deploy_project` for env changes or after a
   new push (production restarts do not re-apply env).

# skill: fiskmas-docker

> Write a Fiskmås-compatible Dockerfile. Use when building an image to deploy to Fiskmås — PORT, health path, amd64, size and memory caps, non-root, and no secrets baked in.

# Fiskmås Docker contract

Fiskmås runs one container per project. Force Docker: build the image locally
and push to the registry; Fiskmås does not remote-build from git in v1.

## Required

- **HTTP only.** Listen on `0.0.0.0:$PORT`. Fiskmås injects `PORT=8080`. Bind
  to `0.0.0.0` (not `127.0.0.1`) or the proxy cannot reach it.
- **Health path.** Serve a health endpoint, default `/health` (or `/`). If it
  does not respond, the deploy is marked `failed`/`unhealthy`.
- **linux/amd64 only.** Multi-arch is ignored in v1.
- **Ephemeral filesystem.** No persistent volumes on the app container. Use the
  shared MariaDB: `DATABASE_URL` (and `MYSQL_*`) are injected on `create_project`.
  The DB host is injected for you (a cluster-FQDN on production) — do not
  hardcode it, and do not override it via `set_env` (that replaces the whole
  env and drops the credentials).
- **Non-root if possible.** Run as an unprivileged user (`USER`).
- **No secrets baked in.** Pass secrets via env (`set_env`), never `ARG`/`ENV`
  in the image. Env is encrypted at rest on the server.

## Hard limits

- **No privileged mode, no `docker.sock`.**
- **No extra published ports.** Only the proxied 8080 is exposed.
- **Image size cap ~1 GB.** Keep base images small (`alpine`, `distroless`,
  `scratch` where practical).
- **Memory cap.** Plan-driven: **256 MB** (Free) / **512 MB** (Plus) per project. OOM → `failed`.
- **CPU cap.** **0.2 CPU** (Free) / **1 CPU** (Plus) per project.

## Example

```dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
ENV PORT=8080
EXPOSE 8080
USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/health || exit 1
CMD ["node", "server.js"]
```

## Push to the registry

```bash
# Your Fiskmås API token (shown once on the email verify page) is also your
# registry credential:
docker login -u <your-API-token> registry.fiskmas.dev
docker build -t registry.fiskmas.dev/<owner>/<slug>:v1 .
docker push registry.fiskmas.dev/<owner>/<slug>:v1
# resolve to a digest for a stable deploy:
docker inspect --format='{{.Config.Digest}}' ...   # or use registry API
```

Only `registry.fiskmas.dev/<owner>/<slug>/...` images run. Arbitrary
`docker.io` images are rejected so apps cannot run on your IP.
