Withvibe plugin creator
Skill withvibe/withvibe-skills/skills/withvibe-plugin-creator
Help a developer build a new WithVibe plugin from scratch — manifest.yaml, Dockerfile, HTTP server with health/UI/MCP endpoints, optional shared-postgres storage. Trigger when the user asks to build, scaffold, create, or write a WithVibe plugin (e.g. "I want to build a plugin for withvibe", "scaffold a withvibe plugin", "how do I add a plugin to withvibe", "write a roadmap-like plugin for X"). Walks through scope decisions (env vs workspace), storage, UI, MCP tools, builds the container, and installs it via the workspace admin Plugins page.From its SKILL.md
npx -y skills add withvibe/withvibe-skills --skill withvibe-plugin-creatorAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
15.7 KB, ~4.0k tokens by cl100k_base, as published. Nobody here has run it
Build a WithVibe plugin
You're helping a developer create a new WithVibe plugin. A WithVibe plugin is a single Docker container the platform spawns according to a manifest.yaml. The container exposes HTTP endpoints for health, optional UI, and optional MCP tools the AI orchestrator can call.
The reference implementation is the withvibe-roadmap plugin — read it before doing anything bespoke. It's ~250 lines and exercises every plugin surface (env scope, shared-postgres, htmx UI, MCP).
Your job: ask just enough questions to lock the shape, scaffold the files, build the image, and walk the user through installing it.
The contract — what a WithVibe plugin is
A plugin is an OCI image plus a manifest. WithVibe handles spawning, networking, proxying, and storage provisioning; the plugin just serves HTTP on port 8080.
manifest.yaml — the install input
The entire install input is one YAML file. Users paste it into Plugins → Install plugin in the workspace admin UI.
id: withvibe.<name> # reverse-DNS-ish; becomes URL + MCP tool prefix
name: <Display Name>
description: <one-line summary shown in admin UI>
version: 1.0.0
icon: <lucide-icon-name> # e.g. list-todo, calendar, sticky-note
image: local/<name>:1.0 # OCI ref the platform pulls/locates
scope: env # see "Scope" below
storage: # omit if stateless
kind: shared-postgres
ui: # omit if no UI
path: /ui
websocket: false # true if the iframe holds a WebSocket open
mcp: # omit if no MCP tools
enabled: true
path: /mcp
HTTP contract the container must satisfy
| Method/path | Required? | Purpose |
|---|---|---|
GET /health | Always | Returns 200 quickly. The platform health probe. |
GET <ui.path> | If ui: is set | Serves the iframe entry HTML. |
POST <mcp.path> (and GET for streamable transport) | If mcp.enabled | Streamable-HTTP MCP transport endpoint. |
The container always listens on 8080. Expose it in the Dockerfile and don't make it configurable — the platform's proxy targets :8080 unconditionally.
What the platform injects
At spawn, the platform sets these env vars in the container:
| Env var | When | Notes |
|---|---|---|
DATABASE_URL | If storage.kind: shared-postgres | Points at a dedicated role on the withvibe_plugins Postgres DB. The role can only reach its own schema. |
PGSCHEMA | If storage.kind: shared-postgres | The schema name (e.g. roadmap_env_abc123). Use it as the search_path or qualify every table. |
NODE_ENV=production | Always | Standard. |
The platform also forwards these request headers on every proxied call (UI and MCP):
| Header | Meaning |
|---|---|
x-withvibe-env-id | Which env this request is for (always set, even for workspace-scoped plugins, when known). |
x-withvibe-user-id | The acting user (UI) or null for AI-initiated MCP calls. |
x-withvibe-actor | user or ai — convenient for attribution in event logs. |
Use these for attribution; don't trust client-supplied values.
Step 0 — Ask the four questions
Before scaffolding, get answers to these. Default to the recommended choice unless the user pushes back.
- What does the plugin do? — one sentence, will become
descriptionand shape the MCP tool surface. - Scope:
envorworkspace?env(recommended for most): one container per env. State is per-env automatically. Use when the plugin operates on a specific env's repos/tasks (roadmap, kanban, code review board, per-feature notes).workspace: one container per workspace, shared across envs. Use for cross-cutting tools (org-wide directory, shared library, single dashboard).
- Does it need to remember anything?
- No → omit
storage:. Stateless plugins are simpler to ship and update. - Yes →
storage: { kind: shared-postgres }. Platform-managed Postgres, isolated per (env, plugin) or (workspace, plugin).
- No → omit
- Surfaces to expose:
- UI — an iframe shown in the env's plugin panel (or workspace settings, for workspace scope). Needed if humans interact with the plugin.
- MCP — tools the AI orchestrator can call. Needed if the AI should drive the plugin programmatically (roadmap-style).
- Most plugins want both.
Step 1 — Scaffold the files
Pick a kebab-case <name> (e.g. roadmap, code-review, release-notes). Create:
my-plugin/
├── manifest.yaml # the install input
├── Dockerfile # builds the runtime image
├── package.json # npm deps
├── server.js # express entry, HTTP + MCP routes
├── db.js # pg pool, schema init (only if storage)
├── mcp.js # MCP tool registrations (only if MCP)
├── ui.js # HTML rendering (only if UI)
├── README.md
├── LICENSE # Apache 2.0 if matching WithVibe's plugin reference
└── .dockerignore
You don't have to use Node.js. Any language that can serve HTTP works. Node is what the reference plugin uses and what the WithVibe team supports best — recommend it for first plugins.
manifest.yaml
# SPDX-FileCopyrightText: <year> <author>
# SPDX-License-Identifier: Apache-2.0
id: <reverse-dns>.<name>
name: <Display Name>
description: <one sentence>
version: 1.0.0
icon: <lucide-icon-name>
image: local/<name>:1.0
scope: env # or workspace
storage: # omit if stateless
kind: shared-postgres
ui: # omit if no UI
path: /ui
websocket: false
mcp: # omit if no MCP
enabled: true
path: /mcp
Dockerfile — copy this verbatim, adjust the file list
# Multi-stage: install deps in builder, drop npm from runtime.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
FROM node:20-alpine
WORKDIR /app
# Strip the npm CLI from the runtime image — once deps are installed we
# never need npm or npx. Removes ~95% of base-image CVE noise.
RUN rm -rf /usr/local/lib/node_modules/npm \
/usr/local/bin/npm \
/usr/local/bin/npx
COPY --from=builder /app/node_modules ./node_modules
COPY server.js db.js mcp.js ui.js ./
EXPOSE 8080
ENV NODE_ENV=production
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health || exit 1
CMD ["node", "server.js"]
package.json
{
"name": "@withvibe-plugins/<name>",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"express": "^5",
"pg": "^8",
"@modelcontextprotocol/sdk": "^1",
"zod": "^4"
}
}
Drop pg if no storage, drop @modelcontextprotocol/sdk + zod if no MCP.
server.js — the canonical shape
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { initSchema } from "./db.js";
import { newMcpServer, registerTools } from "./mcp.js";
import { renderShell, renderApp } from "./ui.js";
await initSchema(); // only if you have storage
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// Health — must be cheap and always 200 when the process is up.
app.get("/health", (_req, res) => res.json({ ok: true }));
// MCP — stateless: one transport per request.
app.all("/mcp", async (req, res) => {
const server = newMcpServer();
registerTools(server, req); // pass req so tools can read x-withvibe-* headers
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => { void transport.close(); void server.close(); });
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: String(err?.message ?? err) },
id: null,
});
}
}
});
// UI
app.get("/ui", async (_req, res) => res.send(renderShell(await renderApp())));
app.get("/ui/app", async (_req, res) => res.send(await renderApp())); // re-render fragment
app.listen(8080);
Step 2 — Watch the proxy-mount gotcha
The WithVibe proxy mounts each plugin behind:
/api/plugins/view/<plugin-id>/env/<envId>/
Inside the iframe, every URL must be relative. <a href="/foo"> will escape the mount and 404. Use <a href="foo"> or <a href="./foo">. Same for hx-post, <form action>, <img src>, fetch URLs, etc.
If you forget this, every link in your UI will break the moment a user opens it. There is no graceful fallback — the URLs just go to the WithVibe root.
Step 3 — Storage (if you said yes)
shared-postgres storage means the platform creates a dedicated role and schema per (env, plugin) or (workspace, plugin), and injects:
DATABASE_URL— connect withpgas usual.PGSCHEMA— the schema name. Set it assearch_pathon the pool.
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
pool.on("connect", (client) => {
client.query(`SET search_path TO "${process.env.PGSCHEMA}"`);
});
export async function initSchema() {
await pool.query(`
CREATE TABLE IF NOT EXISTS thing (
id text PRIMARY KEY,
data jsonb NOT NULL
);
`);
}
Things to know:
- You own the schema. Create/alter tables in
initSchema(). There's no shared migration runner. - Don't quote-escape the schema name into queries.
SET search_pathonce per connection, then use unqualified table names everywhere. - The role can't see other plugins or the main
withvibeDB. No data leakage risk by default. - Backups are the user's responsibility at the WithVibe install level. Don't roll your own.
For non-Postgres state (files, KV), there's no built-in option yet — write to /tmp if it's truly ephemeral, or wait for the API to add a storage kind that fits.
Step 4 — MCP tools (if you said yes)
Tools are how the AI orchestrator drives your plugin. Keep them few and well-named — the AI sees them in the env's tool list.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { getThing, putThing } from "./db.js";
export function newMcpServer() {
return new McpServer({ name: "<name>", version: "1.0.0" });
}
export function registerTools(server, req) {
const actor = req.get("x-withvibe-actor") ?? "ai";
const userId = req.get("x-withvibe-user-id") ?? null;
server.tool(
"get_thing",
{ id: z.string() },
async ({ id }) => ({ content: [{ type: "text", text: JSON.stringify(await getThing(id)) }] }),
);
server.tool(
"put_thing",
{ id: z.string(), data: z.record(z.unknown()) },
async ({ id, data }) => {
await putThing(id, data, { actor, userId });
return { content: [{ type: "text", text: "ok" }] };
},
);
}
Guidance the reference plugin learned the hard way:
- Return the new state in every mutation tool's response. The AI otherwise calls
get_*after every write to confirm — wasteful round-trips. The roadmap plugin always returns the full plan in the trailing text aftercomplete_task/add_phase/ etc. - Validate with Zod — bad input from the AI is real and frequent. The error message goes back to the model and it will retry.
- Don't expose admin tools. Schema migrations, bulk deletes, etc. belong in operator paths (REST routes you call manually), not MCP. The AI should never destroy data it can't justify.
Step 5 — Build and test locally
docker build -t local/<name>:1.0 .
# Run standalone (only needed for storage-using plugins)
docker run --rm -p 8080:8080 \
-e DATABASE_URL="postgres://[email protected]:5432/withvibe_plugins" \
-e PGSCHEMA="<name>_dev" \
local/<name>:1.0
# Smoke test
curl -fsS http://localhost:8080/health
open http://localhost:8080/ui
For Postgres testing you can use the WithVibe install's own withvibe_plugins DB (its credentials are in the install dir's .env), or spin up a throwaway:
docker run --rm -d --name pg-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:17-alpine
psql -h localhost -U postgres -c 'CREATE DATABASE withvibe_plugins;'
Step 6 — Install in WithVibe
- Workspace admin → Plugins → Install plugin.
- Paste the contents of
manifest.yaml. That's the entire install input. - The platform pulls (or locates) the image and registers the plugin.
- For env-scoped plugins: open any env. The plugin's tab appears in the env's plugin panel.
- For workspace-scoped: appears in the workspace settings area.
Updating after a rebuild
Each rebuild needs a manifest version bump (1.0 → 1.1) and a matching image tag. Then hit Update on the plugin row — running instances are stopped so the next env start picks up the new image. There is no live reload.
If the image tag stays the same but the contents changed, Docker may not pull the new layer (especially in from-registry mode). Bump the tag.
Distributing beyond your own install
For now, plugins are distributed by sharing the image (push to GHCR / Docker Hub / private registry) plus the manifest. Anyone with a WithVibe install can paste the manifest. There's no plugin registry yet.
Common mistakes to catch early
- Absolute URLs in the UI. Symptom: clicking anything jumps out of the plugin frame. Fix: every URL relative.
- Health endpoint slow or 500. The platform marks the plugin unhealthy and stops routing. Make
/healtha cheap constant-time response — no DB pings, no work. - Trusting
x-withvibe-user-idfor auth decisions across plugins. Use it for attribution only. There's no per-user permission system inside the plugin yet — anyone with env access can call any of the plugin's tools. - Logging the
DATABASE_URL. Password's in there. Redact before logging. - Forgetting
EXPOSE 8080. Some setups fail without it. - MCP tool names that collide. All tools across all plugins in an env are flattened into one namespace for the AI. Prefix yours with the plugin name (e.g.
roadmap_add_tasknotadd_task) if there's any risk of clash.
When to consult the reference
If anything in this skill conflicts with what you find in withvibe-roadmap, trust the reference. It's the working, in-production implementation. Read these files in order when stuck:
manifest.yaml— minimal valid manifest.server.js— the exact HTTP wiring above.mcp.js— full MCP tool registration pattern.db.js— search_path pattern + schema init.Dockerfile— multi-stage + npm-stripping.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.