add-cserver-plugin
Skill9 starsChanged 11 days agoApache-2.0View on GitHub
Author a `cserver.*` cMeta category that renders an interactive web page (HTML + CSS + JS + AJAX) and runs unchanged on BOTH the engine's internal `cserver` app and the cTuning cPlatform (`ctuning.server`). Covers the `web_()` contract, the `urls`/`query`/`misc` inputs, the `native_action` AJAX pattern, forwarding page params into AJAX calls, asset cache-busting, dark-theme handling, the cPlatform project pointer, and how to run/verify on both hosts. Use when the user asks to "add a cserver plugin/project", "add a page/dashboard/visualization to the platform", or "make a cMeta category render a web UI".
- Sends data out
In this repository8 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- llms.txtllms.txt
- add-pluginSkill
- add-repoSkill
- release-cmetaSkill
- use-cmeta-cliSkill
- use-cmeta-pythonSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 45
If you create the folder by hand (mkdir + _cmeta.yaml), register just that one artifact instead of rebuilding the whole home:
- Distinctive: line 228
When debugging a "works here, broken there" report, check which copy of the JS actually ran before assuming the server logic is wrong:
- Very specific: line 273
Put sticky bars under the shell's bar: Use top: var(--ctp-bar-h, 0px).
- Very specific: line 274
Add the same offset to scroll-margin-top and to any fixed spinner at the top.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
add-cserver-plugin — one category, two web hosts
A cserver plugin is a cMeta category named cserver.<name> whose api/v1.py
renders an interactive HTML page. The same category is served by two different
hosts, and a plugin is only "done" when it works on both:
| internal cserver (engine) | cPlatform (ctuning.server) | |
| App | cmeta/internal-repo/app/cserver/src/app.py | …-platform/repo/app/ctuning.server/dev/project_v1.py |
| Default port | 8004 | 8118 |
| Page URL | /<name> → category cserver.<name> | /project/<username>/<project>/ |
urls['url'] | http://…/<name>/? | http://…/project/<user>/<project>/? |
urls['url_files'] | http://…/<name>/ | http://…/project/<user>/<project>/files/ |
| Wiring needed | none — route derives the category from the path | a project-<name>.json pointer (see §5) |
| Auth | optional api_keys in the cserver config | session + private/private_usernames |
command from query | any (defaults to web) | only if listed in allowed_commands |
misc | minimal, no theme | theme, theme_bg_color, ctuning_server: True |
The two hosts differ only in launch and URL convention. Everything else is
identical, which is exactly why the rules below ("build every URL from urls",
"handle the dark theme", "cache-bust your assets") are not optional polish —
each one is a place where a plugin silently works on one host and breaks on the
other.
1. Scaffold
cx category add <repo>:cserver.<name> # creates the folder, _cmeta.yaml, api/v1.py stub
cx category find cserver.<name> # confirm it resolves; note the UIDGive it a name and a desc in _cmeta.yaml: they are its card. The home page
of the internal cserver (/, which is /projects, category cserver.projects) lists
every cserver.* category whose api defines web_, straight from the index, with no
registration. The part of name before - is the card's title, and desc (or the
rest of name) its text. cx cserver.projects pages prints the same list.
If you create the folder by hand (mkdir + _cmeta.yaml), register just
that one artifact instead of rebuilding the whole home:
cx category index <repo>:cserver.<name> # index a hand-made folder
cx category reindex cserver.<name> # after editing its _cmeta.* meta (nothing written; since cMeta 0.34.0)
cx category update <repo>:cserver.<name> # the same, but it rewrites the file too
cx repo reindex <repo> # the whole repository, when several things changed in itcx --reindex walks every category and is slow — keep it for a wiped
CMETA_HOME or a bulk change across repositories. Editing api/, files/ or
src/ needs no reindex at all; a server restart (or --reload) picks those up.
Layout:
category/cserver.<name>/
_cmeta.yaml # artifact UID + category: category,dd9ea50e7f76467f
api/v1.py # web_() — renders the page AND serves its AJAX
files/
index.html # page body only (no <html>/<head>)
css/layout.css
js/graph.js # your page logic
*.json # data, if any2. The web_ contract
from cmeta.category import InitCategory
class Category(InitCategory):
def __init__(self, *args, **kwargs):
super().__init__(*args, module_file_path=__file__, **kwargs)
def web_(self, ctx, urls, query={}, misc={}):
...- The trailing
_is required. Methods ending in a single_are called with unpacked kwargs (func(**params)); without it cMeta passes one positional dict and you get aTypeError. Commandweb→ methodweb_. urls—url(this page's?-terminated endpoint),url2,url_files,url_server,url_server_js_script(…/static/js/cmeta_server.js). Never hardcode a path; the prefixes differ per host and cPlatform may sit behindCTUNING_SERVER_URL_PREFIX/ a reverse proxy.query— merged GET params and POST JSON body, plususernameon cPlatform andnative_actionfor AJAX calls. Both hosts merge the body: enginecmeta/utils/net.py::unify_request, platformdev/utils.py::unify_request.misc— session bits.themeis'dark'/'light'on cPlatform and absent on the internal cserver.ctx—{'category','command','control':{'con':bool},…}. Re-enter the framework withself.cm.access(...); your own path isself.path.
Return one of:
{'return': 0, 'html_meta': {...}} # full page
{'return': 0, 'json': {...}} # AJAX response (both hosts return it as JSONResponse)On failure return the standard error dict — return self.cm.error('...') — and
check nested calls with if self.cm.catch_error(r): return r. Don't let an
exception escape web_(): both hosts expect a dict. For an AJAX branch,
prefer returning {'return': 0, 'json': {'error': '...'}} so the page can
render the message instead of the request failing. See
docs/error-handling.md.
html_meta keys the hosts consume: html (body, rendered safe), page_title,
page_extra_style (inject <script>/<link>), global_footer. cPlatform also
adds project_home_url / projects_home_url when the project sets add_home /
add_back_to_projects.
3. The native_action AJAX pattern
One category serves both the page and its API. The page JS calls back into
the same URL with native_action=<x>; web_ branches on it.
Both hosts serve cmeta_server.js, which exposes:
accessCT(api_url, dict) // fetch POST, JSON body, returns parsed JSONForwarding page params into the AJAX call
The page may be opened with params (?limit=5), but the graph data is fetched
by a second request that the server never sees the page URL of. Declare, in
one place, which query keys each action accepts, and forward them:
# Which query keys each AJAX native_action reads from the high-level page request.
# Both the page URL (GET) and POST body fields are honored.
NATIVE_ACTION_PARAMS = {
'load_graph': ['limit', 'max_nodes'],
}
def _js(obj):
"""Serialize to a JSON/JS literal safe to embed inside a <script> tag."""
return json.dumps(obj).replace('</', '<\\/')def web_(self, ctx, urls, query={}, misc={}):
native_action = query.get('native_action', '')
if native_action == 'load_graph':
return self._build_graph(query) # -> {'return':0,'json':{...}}
# allow-listed params the user passed to the page, per action
initial_params = {}
for action, keys in NATIVE_ACTION_PARAMS.items():
picked = {k: query[k] for k in keys
if k in query and query[k] not in (None, '')}
if picked:
initial_params[action] = picked…embedded into the page as CONFIG.params / CONFIG.param_keys, then on the JS
side merged from three sources (later overriding earlier):
function collectActionParams(action) {
const params = {};
if (CONFIG.params && CONFIG.params[action]) // 1. server-forwarded
Object.assign(params, CONFIG.params[action]);
const allowed = (CONFIG.param_keys && CONFIG.param_keys[action]) || [];
const sp = new URLSearchParams(window.location.search || '');
allowed.forEach(k => { if (sp.has(k)) params[k] = sp.get(k); }); // 2. live URL
document.querySelectorAll('[data-action="' + action + '"]').forEach(el => {
if (el.name && el.value !== '') params[el.name] = el.value; // 3. UI fields
});
return params;
}
const params = collectActionParams('load_graph');
const out = await accessCT(CONFIG.api_url + 'native_action=load_graph', params);Give the page real controls rather than making the user hand-edit the URL — an
<input name="limit" data-action="load_graph"> plus a Reload button is enough,
since collectActionParams already reads them. Seed those inputs from the
effective params on load, and write the params of the last request back with
history.replaceState so the view stays shareable and a plain reload reproduces
it (the server re-forwards them).
Adding a param is then a one-line change to NATIVE_ACTION_PARAMS plus an
input whose name matches. Only declared keys are forwarded — an undeclared or
misspelled key (num_nodes vs max_nodes) is silently ignored by design.
4. Three gotchas that only bite on one host
4.1 Cache-bust your files/ assets — the cross-host trap
Neither host sends Cache-Control for artifact files (engine task_files,
platform project_files), so browsers apply heuristic freshness and can keep
running a stale graph.js for a long time after you edit it. The classic
symptom: the plugin works on the host you just started and appears broken on the
other, with a ReferenceError for a function you know you wrote.
Fix it in the category, so both hosts benefit and neither server needs changing:
def _asset(path_to_files, url_files, rel):
"""Build a versioned URL for a file under files/ (mtime -> new URL per edit)."""
url = url_files + rel
try:
return f'{url}?v={int(os.path.getmtime(os.path.join(path_to_files, *rel.split("/"))))}'
except Exception:
return urlf'<link rel="stylesheet" href="{_asset(path_to_files, url_files, "css/layout.css")}">\n'
f'<script src="{_asset(path_to_files, url_files, "js/graph.js")}"></script>\n'When debugging a "works here, broken there" report, check which copy of the JS actually ran before assuming the server logic is wrong:
// in the page console / javascript_tool
[...document.scripts].map(s => s.src)
typeof myFunctionAddedInTheLastEdit // "undefined" => stale cache4.2 Handle the dark theme
cPlatform passes the user's theme; the internal cserver has none. A page styled only for light renders as a near-white panel with light-grey labels for every dark-theme platform user.
theme = misc.get('theme', '')
dark = 'true' if theme == 'dark' else 'false' # into CONFIG.dark_modeconst darkMode = (typeof CONFIG !== 'undefined' && CONFIG.dark_mode) === true;
if (darkMode) document.getElementById('<root-id>').classList.add('dark');Then style #<root-id>.dark … in your CSS (panel background, borders, control
inputs, legend text) and flip any colors your JS sets inline — SVG strokes,
label fills, canvas colors — since CSS can't reach those.
4.3 Live inside the cPlatform's shell
Since its layout of 2026-10-31 the cPlatform wraps a page in a shell:
- a sticky top bar (
.ctp-bar, z-index 40,--ctp-bar-h= 56px); - a sticky menu on the left (
.ctp-side, no z-index, before the page in the DOM); - the theme on
html[data-ctp-theme].
Three traps, found 2026-11-04 when a dark page cut the shell's menu:
- Paint nothing past the page's box. A dark page may fill the window on
the internal cserver with
box-shadow: 0 0 0 100vmaxandclip-path: inset(0 -100vmax). In the shell that band paints over the menu: the clip-path makes a stacking context, painted after the menu in tree order.elementFromPointstill finds the menu, because shadows are not hit-tested, so check this with screenshot pixels. - Put sticky bars under the shell's bar. Use
top: var(--ctp-bar-h, 0px). Add the same offset toscroll-margin-topand to any fixed spinner at the top. - Follow the shell's theme. The browser's own choice
(
localStorage ctp-theme) wins over the account's theme, and it switches without a reload, somisc.themecan disagree with the shell. The shell'sbody.ctpbackground is!important, so the page cannot colour the window.
So tell the page it is hosted, and branch on that:
'hosted': bool(misc.get('ctuning_server')), # into CONFIGif (CONFIG.hosted) { // and hide the page's own theme switch
root.classList.add('hosted');
const html = document.documentElement;
const follow = () => { const t = html.getAttribute('data-ctp-theme');
root.classList.toggle('dark', t ? t === 'dark' : CONFIG.dark_mode === true); };
follow();
new MutationObserver(follow).observe(html, {attributes: true, attributeFilter: ['data-ctp-theme']});
}#root.dark:not(.hosted) { box-shadow: 0 0 0 100vmax var(--bg); clip-path: inset(0 -100vmax); } /* engine only */
#root.hosted { --top: var(--ctp-bar-h, 0px); --bg: var(--ctp-bg, #fff); } /* the shell's */
.bar { position: sticky; top: var(--top, 0px); }To check it without deploying, render the page's web_ with the cPlatform's
misc ({'ctuning_server': True, 'theme': 'dark'}) into a copy of the shell:
- the platform's
ctp-layout.cssand<html data-ctp-theme="dark">; <body class="ctp">with aheader.ctp-bar;- an
aside.ctp-sidewith enough links to fill the window; - a tall spacer after the page, so it can scroll.
In a headless browser, compare a pixel of the menu below the page's top edge with one at the menu's top, before and after a scroll.
5. Wire it into cPlatform
The internal cserver needs nothing: /<name> already maps to cserver.<name>.
cPlatform needs a userspace pointer at
…-platform/repo/ctuning.user/___<xx>/___<username>/projects/project-<name>.json:
{
"name": "cMeta connections",
"menu_name": "cMeta connections",
"private": true,
"private_usernames": ["gh_<user>", "go_<user>@example.com"],
"cmeta_params_for_html": {
"category": "cserver.<name>,<UID>",
"command": "web",
"api": 1
},
"add_back_to_projects": true,
"cmeta_artifact_for_files": "category::cserver.<name>,<UID>"
}cmeta_artifact_for_filesis what makes…/files/**resolve to the category'sfiles/dir (path-traversal guarded).- Optional:
group(+group_sort) lists the project under a heading on the user's projects page (<li class="ctp-project-group">, styled by the platform stylesheet or the user'sproject_extra_html); the course/install/tools pointers of___cmetashow the pattern. - Optional:
allowed_commands(permits?command=…),skip_files_prefix(drops/files/fromurl_files),logged,sort,add_home. - Reference by
alias,UID. The UID is authoritative and the alias advisory, so the folder can gain/lose acserver.prefix without breaking the pointer.
6. Run and verify — both hosts, every time
# A) internal cserver (from CMETA_HOME)
cx app run cserver # -> http://127.0.0.1:8004/<name>
cx app run cserver --param.port=8010 --con
# If the launcher can't find _run.bat, run uvicorn directly from the app artifact:
# cd <cmeta>/internal-repo/app/cserver && python -m uvicorn src.app:app --host 127.0.0.1 --port 8004
# B) cPlatform (from repo/app/ctuning.server/)
./6_run_uvicorn.sh # 6_run_uvicorn.bat on Windows -> :8118
# -> http://localhost:8118/project/<username>/<name>/Exercise the AJAX branch straight from the shell (no browser, no session on the internal cserver):
curl -s -X POST 'http://127.0.0.1:8004/<name>/?native_action=load_graph' \
-H 'Content-Type: application/json' -d '{"limit":"2"}'Then in a browser, on both hosts:
- Page renders; no console errors.
- Internal cserver: the page is a card on the home page
/(only a footnote there means its api has noweb_). ?<param>=Nin the URL changes the result and seeds the matching UI input.- Changing an input + Reload re-fetches and updates the URL.
- The loaded asset URLs carry a
?v=stamp matching your latest edit. - cPlatform in dark theme is legible (panel, labels, controls, node strokes).
- cPlatform: the shell's menu stays whole once the page scrolls, sticky bars stop under its top bar, and its theme switch turns the page too (§4.3).
- All links/assets came from
urls— nothing hardcoded.
7. Reference implementations
The pages the engine ships, in cmeta/internal-repo/category/:
cserver.projects— the home page/: everycserver.*category as a card, found in the index. The smallest complete page to copy:web_with one JSON action (?native_action=projects),_asset()cache-busting, the dark theme (?dark=1here,misc['theme']on the cPlatform).cserver.browse— search, browse and graph of every plugged repository: a dozen JSON actions in oneweb_(search,graph,options,open,save,create, ...), the state of the page kept in its address.cserver.cref— the page of one artifact named by its cRef (/cref/<category>::<alias>,<UID>[/<relative path>]): a page whose address carries a path.
Related skills: add-plugin (category scaffolding + api/v1.py conventions),
use-cmeta-cli, use-cmeta-python.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

