- name
- create-evenfire-plugin
- description
- Build an Evenfire plugin end to end: a WorkflowRecipe with a sandbox web UI, a credentialed backend, an optional MCP server and database, webhooks, OAuth, or an agentic/snippet workflow. Use when the task is to design, scaffold, write, change, containerize, or wire a plugin or recipe for a Clerum/Evenfire cluster, or when you need the exact WorkflowRecipe fields, enums, validation rules, namespace/credential boundaries, image requirements, or the build-and-deploy loop.
# Building an Evenfire plugin (WorkflowRecipe)
> **Verified against Evenfire `dev` at commit `21d9a7d5d` (2026-08-11).**
> `git describe`: `v0.3.0-915-g21d9a7d5d`. CRD API `clerum.io/v1alpha1`,
> `clerum-crds` chart `0.7.0`. Field names, enums, endpoints, and commands below
> match that revision. See [VERSIONS.md](../VERSIONS.md).
A plugin is one Kubernetes custom resource, `apiVersion: clerum.io/v1alpha1`,
`kind: WorkflowRecipe`. The platform reconciles it into Deployments,
StatefulSets, CronJobs, MCP servers, a sandboxed web UI, NetworkPolicies, and
workflow runs. A UI plugin is normally a small multi-service repo (`ui/`, `api/`,
`mcp/`, `recipe/`) whose recipe wires those images together.
Everything in this skill is verified against the live platform code
(`clerum.io` CRDs, `workflow-recipes`, `host-context-controller`, `control-api`,
`rpc-proxy`, `mcp-host`) and two shipped plugins. Never invent a field, enum,
tool, or endpoint that is not listed here. When you need a value that is not
here, read the CRD at `charts/clerum-crds/crds/workflowrecipe.yaml` and the WRC
reconciler rather than guessing.
To publish and install what you build, use the `publish-evenfire-plugin` skill.
To run a cluster and debug a deployed recipe, use the `run-debug-evenfire` skill.
## 1. Pick the archetype first
One field decides the plugin's whole lifecycle: `isWorkflow = spec.steps.length > 0`.
| Archetype | Shape | "Healthy" means | Typical use |
|---|---|---|---|
| Pure workloads | `workloads`, no `steps` | every workload Deployment/StatefulSet applied and its pods Ready | a UI app + backend + DB, or a standalone MCP server |
| Pure workflow | `steps`, no long-running `workloads` | the run reaches `completed` | agentic or snippet pipelines, cron jobs |
| Hybrid | both | workloads Ready and runs succeed | a UI app that also emits desktop notifications, a workflow with a sidecar |
A recipe MUST populate at least one of `workloads` or `steps` (rule R2).
Deploy behavior differs by archetype and this bites people (section 9): a
recipe **with** `steps` does not re-apply workload images once it is `active`,
a recipe **without** `steps` does. Adding even a dormant keepalive step (as the
notifications pattern below does) flips a UI plugin into the "does not
auto-propagate image edits" class.
## 2. The credential boundary (the core mental model)
The platform routes each workload to one of three namespaces by rule, and you
never set `metadata.namespace` yourself:
- workload has `transport` -> `mcp-server`
- workload is the one named by `spec.ui.workloadRef` -> `sandbox-ui`
- every other workload -> `sandbox-recipes`
- the WorkflowRecipe object itself always lives in `sandbox-recipes`
`sandbox-ui` is the credential-free browser-facing zone. `sandbox-recipes` is
where Secrets live. This gives the canonical UI plugin its shape:
```
browser (Electron WebContentsView)
| rpc-proxy strips Cookie/Authorization/x-clerum-*, injects x-clerum-user
v
ui (sandbox-ui) nginx-unprivileged, no Secrets, serves the SPA,
| reverse-proxies /api/* to the backend
v spec.ui.egress.internal / spec.bindings (default-deny NetworkPolicy)
api (sandbox-recipes) holds Secrets via envSecret, talks to db + third parties
|
db (sandbox-recipes) StatefulSet + PVC
mcp (mcp-server) optional MCP server, same dataset, for the chat agent
```
Hard rules that follow from this:
- The UI workload MUST NOT declare `envSecret`, `imagePullSecrets`, or any
credential reference. There is no Secret-resolution path in `sandbox-ui`; a UI
pod that references a Secret sits in `CreateContainerConfigError` forever.
- Credentialed code lives on a sibling backend in `sandbox-recipes`, reached
from the UI over `spec.ui.egress.internal[]` (the UI's nginx `proxy_pass`).
- The backend trusts identity ONLY from the rpc-proxy-injected `X-Clerum-User`
header. rpc-proxy strips every client-supplied `x-clerum-*`, `Cookie`, and
`Authorization` header, so the embed cannot spoof a user. Never read a user id
from the request body or from embed JS.
- A Secret is read from the namespace of the workload that consumes it, and
nothing copies Secrets across namespaces. If both the backend
(`sandbox-recipes`) and the MCP server (`mcp-server`) read the same Secret, it
must exist, with the same value, in BOTH namespaces.
## 3. The repo scaffold (multi-service UI plugin)
```
my-plugin/
api/ Dockerfile package.json tsconfig.json src/ migrations/ -> sandbox-recipes
ui/ Dockerfile package.json vite.config.ts src/ nginx/ -> sandbox-ui
mcp/ Dockerfile package.json src/ -> mcp-server (optional)
recipe/ my-plugin.yaml my-plugin.json the WorkflowRecipe
docker-compose.dev.yml full local stack, no cluster needed
Makefile build-multiarch / dev / recipe-json / publish
README.md
```
### 3.1 The three Dockerfiles (exact, verified shapes)
Ports are fixed by convention and must match the recipe: **ui 8080, api 8080,
mcp 3000**. All images run non-root with a numeric `USER` so `runAsNonRoot` can
verify them, and must be multi-arch (`linux/amd64` + `linux/arm64`).
`ui/Dockerfile`, static SPA served by unprivileged nginx. Build the bundle once
on the native builder to avoid emulating esbuild/rollup under QEMU:
```dockerfile
FROM --platform=$BUILDPLATFORM node:20-alpine AS build
WORKDIR /app
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build # -> /app/dist, no inline scripts
FROM nginxinc/nginx-unprivileged:1.27-alpine
ENV API_HOST=api
ENV API_PORT=8080
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
USER 101
EXPOSE 8080
```
`ui/nginx/default.conf.template` (envsubst fills `API_HOST`/`API_PORT`):
```nginx
server {
listen 8080;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://${API_HOST}:${API_PORT};
proxy_set_header X-Clerum-User $http_x_clerum_user;
}
location / { try_files $uri /index.html; }
}
```
`api/Dockerfile`, credentialed backend:
```dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:20-alpine AS runtime
WORKDIR /app
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER 1000
EXPOSE 8080
CMD ["node", "dist/server.js"]
```
`mcp/Dockerfile` (only if the plugin exposes an MCP server): identical to the
api runtime but `EXPOSE 3000`. See the `create-evenfire-mcp-server` skill for
the server code contract.
### 3.2 Version stamping
Thread one `APP_VERSION` build-arg through:
- api: `ARG APP_VERSION` -> `ENV APP_VERSION` -> `process.env.APP_VERSION ?? 'dev'`
reported by `/api/healthz`.
- ui: `ARG APP_VERSION` -> `ENV APP_VERSION` -> Vite `define: { __APP_VERSION__:
JSON.stringify(process.env.APP_VERSION || 'dev') }`, shown in the app shell.
Do not stamp the mcp image with a build-time version unless it actually reads
one; MCP servers version through `registry.json`/`package.json`.
### 3.3 Local dev loop (no cluster)
`docker-compose.dev.yml` runs db + api + mcp + ui together. Per-service watch
mode also works: `cd api && npm run dev` (tsx watch), `cd ui && npm run dev`
(Vite dev server that proxies `/api` to the local api and injects a dev
`X-Clerum-User`). Get the plugin fully working here before touching a cluster.
### 3.4 Multi-arch build and push
```bash
docker buildx create --use --name multiarch # one-time
for svc in api ui mcp; do
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg APP_VERSION="$VERSION" \
--label org.opencontainers.image.source=https://github.com/<org>/<repo> \
--label org.opencontainers.image.licenses=Apache-2.0 \
-t <registry>/<name>-$svc:$VERSION --push "$svc/"
done
```
`<registry>` is a public registry (Docker Hub, GHCR, Quay) for a public plugin,
or `registry.evenfire.ai/<org>` for an org-private one. See `publish-evenfire-plugin`.
## 4. The recipe YAML
`recipe/my-plugin.yaml` wires the images. Full UI + backend + DB + MCP example:
```yaml
apiVersion: clerum.io/v1alpha1
kind: WorkflowRecipe
metadata:
name: my-plugin # DNS-1123, <= 63 chars. Do NOT set metadata.namespace.
spec:
description: |
One-sentence summary first (operators see it in the catalog list).
Then document EVERY Secret an operator must create before install:
its name, keys, which namespace(s), and where each value comes from.
security:
isolationLevel: minimal # minimal | standard | strict
workloads:
- id: db
type: statefulset
image: postgres:16-alpine
port: 5432
env:
- { name: POSTGRES_USER, value: myplugin }
- { name: POSTGRES_DB, value: myplugin }
- { name: PGDATA, value: /var/lib/postgresql/data/pgdata } # subdir, not the mount root
envSecret:
name: my-plugin-secrets
keys:
- { secretKey: pg-password, envVar: POSTGRES_PASSWORD }
volumeMounts:
- { name: pgdata, mountPath: /var/lib/postgresql/data }
volumeClaimTemplates:
- { name: pgdata, storageClass: standard, accessMode: ReadWriteOnce, size: 1Gi }
security:
runAsUser: 70 # postgres:16-alpine runs as uid 70
runAsGroup: 70
fsGroup: 70
addCapabilities: [CHOWN, FOWNER, DAC_OVERRIDE] # initdb needs these
- id: api
type: deployment
image: <registry>/my-plugin-api:1.0.0
port: 8080
envSecret:
name: my-plugin-secrets
keys:
- { secretKey: pg-password, envVar: DATABASE_PASSWORD }
healthCheck: { type: http, path: /api/healthz, port: 8080 }
egressBindings:
- { dns: api.anthropic.com, port: 443 } # every third-party host, exact-host
dependsOn: [db]
- id: ui
type: deployment
image: <registry>/my-plugin-ui:1.0.0
port: 8080
env:
- { name: API_HOST, value: '{{api:host}}' } # template var, resolved at build time
- { name: API_PORT, value: '{{api:port}}' }
healthCheck: { type: http, path: /, port: 8080 }
- id: mcp # optional MCP server, same dataset
type: deployment
image: <registry>/my-plugin-mcp:1.0.0
port: 3000
transport: { type: streamableHttp, path: /mcp }
envSecret:
name: my-plugin-secrets # note: this Secret must ALSO exist in mcp-server ns
keys:
- { secretKey: mcp-api-token, envVar: MCP_API_TOKEN }
ui:
workloadRef: ui # must be a deployment, replicas 1, no transport (R15/R16)
port: 8080 # must be 8080 (rpc-proxy allow-list), else 502 at view time
title: My Plugin
defaultPath: /
egress:
internal:
- { workloadRef: api, port: 8080 } # opens ui -> api (nginx proxy_pass target)
bindings:
- { from: mcp, to: api, port: 8080 } # mcp (mcp-server ns) -> api (sandbox-recipes)
```
### 4.1 Field and enum reference (copy values verbatim)
- `workloads[].type`: `deployment | statefulset | cronjob | job | daemonset`.
A `cronjob` workload MUST set the workload-level `schedule`. Max 25 workloads.
- `workloads[].type` required trio: `id`, `type`, `image`. Common optionals:
`port`, `replicas` (0-20), `command`, `args`, `env`, `envSecret`,
`volumeMounts`, `volumeClaimTemplates`, `resources`, `healthCheck`,
`dependsOn`, `imagePullSecrets`, `oauthClientRefs`, `egressBindings`,
`includeWhen`, `transport`, `security`, `schedule`.
- `env[]` items are `{name, value}` strings only. There is **no** `valueFrom`,
`envFrom`, or `envFromConfigMap`. The only Secret-to-env path is `envSecret`
(`name` + `keys[{secretKey, envVar, optional?}]`).
- `healthCheck`: `{type: http|tcp|exec, path, port, command[], initialDelaySeconds,
periodSeconds, timeoutSeconds, failureThreshold}`. There is **no** `startupProbe`
and **no** `successThreshold`; the one `healthCheck` block serves both liveness
and readiness. For slow starts, widen `initialDelaySeconds`/`failureThreshold`,
or return 503 from the health path until ready.
- `security` overrides: `runAsUser`/`runAsGroup`/`fsGroup` (each min 1, so root
uid 0 is rejected), `addCapabilities` (only `CHOWN|FOWNER|DAC_OVERRIDE|NET_BIND_SERVICE`,
enforced twice), `prepareVolumeOwnership`. Setting `runAsUser` forces
`runAsNonRoot: true`.
- `transport.type`: `streamableHttp | sse | stdio` (author `streamableHttp` for
own-code HTTP servers). `path` defaults matter, set `/mcp`.
- `egressBindings[]`: `{egressClass: exact-host|public-web, dns, port, protocol}`.
`exact-host` (default) opens one DNS host + port. `public-web` opens public TCP
80/443 only and must NOT carry `dns`/`port`. Max 20.
- `volumeClaimTemplates[]`: `{name, storageClass, accessMode, size}`,
`accessMode` is `ReadWriteOnce|ReadOnlyMany|ReadWriteMany`. Use `standard` for
minikube, the cluster's real class for GKE (`standard-rwo`) or DO
(`do-block-storage`). Max 4.
- `security.isolationLevel`: `minimal | standard | strict`, additive, default
`minimal` (root allowed, writable root FS, all caps dropped, seccomp
RuntimeDefault). `standard` adds `runAsNonRoot` + read-only root FS. `strict`
adds pod identity pinned to 65534 + `automountServiceAccountToken: false` +
the PodSecurity `restricted` label. Prefer `minimal` and use a per-workload
`security.runAsUser` when a specific non-root uid is needed.
- `spec.ui`: `workloadRef` + `port` required; optional `title` (<=100 chars),
Ver no GitHub