Skip to content

Extensions (.ckext modules) ​

An extension is a small app that runs inside CivilKit: a form, a check, and a calc sheet. It has one property that matters: the engineering numbers come from CivilKit's verified engine through declared capabilities, and the extension writes only the orchestration. Section properties, capacities and solved forces are the engine's. The module decides which checks, in what order, and with which project-specific factors.

This page is the whole contract. An engineer can write one from it, an AI agent can write one from it, and either can test it with one command without opening the app.

Where they live in Studio ​

Tools → Modules opens the manager: install a .ckext by dropping it on the panel or from the store, see each module's badge, uninstall. Installed modules appear under the Tools menu. New module… opens the builder, where you can write, run and export a module in the browser, or fork an installed one to learn from its source.

The bundle ​

A .ckext is a zip with three files:

manifest.json                 the contract
main.py                       MicroPython: build_ui() and check()
tests/worked-examples.json    cited examples; passing them earns the Verified badge

Zip it from inside the folder so the paths are relative:

bash
zip -r my-module.ckext manifest.json main.py tests

manifest.json ​

json
{
  "id": "com.example.purlin-uplift",
  "name": "Purlin uplift check",
  "version": "1.0.0",
  "description": "AS/NZS 4600 uplift check on the roof purlins.",
  "license": "MIT",
  "civilkitApi": "^1.0",
  "contributes": [{ "point": "design-check", "id": "purlin-uplift", "label": "Purlin uplift" }],
  "capabilities": ["getModel", "getSectionProps"],
  "tests": "tests/worked-examples.json"
}

Required: id (reverse-DNS, the identity), name, version (semver), civilkitApi (^1.0), contributes (an array; design-check is the point the host renders today). capabilities is the least-privilege list: a call to anything not listed throws Capability 'x' not declared in the module manifest. author, homepage and license are shown to the user and confer no trust.

main.py ​

MicroPython, not CPython: json, math and re are there, but re has no flags (re.IGNORECASE does not exist: upper-case both sides instead), there is no typing, no dataclasses, no f-string nesting, and integers divide with / to floats as usual. civilkit is the host module.

Two functions. The host calls build_ui to draw the form and check when the user presses a button with action: "run-check" or changes a field.

python
import json, civilkit

def build_ui(inputs, result, calcLines):
    # inputs: current form values. result / calcLines: the last check output, [] at first.
    return {"type": "panel", "title": "Purlin uplift", "children": [
        {"type": "field", "id": "member_id", "label": "Member", "inputType": "enum",
         "options": [{"value": m["id"], "label": m["id"] + " " + m["section"]} for m in _purlins()],
         "default": inputs.get("member_id", "")},
        {"type": "field", "id": "w_up", "label": "Uplift w* (kN/m)", "inputType": "number",
         "default": inputs.get("w_up", 2.0)},
        {"type": "button", "id": "run", "label": "Run check", "action": "run-check"},
        {"type": "result", "items": result},
        {"type": "calc", "lines": calcLines},
    ]}

def check(inputs):
    props = json.loads(civilkit.getSectionProps("C20019"))
    ...
    return {
        "result": [
            {"label": "phiMb", "value": round(phi_mb, 2), "unit": "kN.m"},
            {"label": "Utilisation", "value": round(util, 3)},
            {"label": "Status", "value": "OK" if util <= 1 else "OVER", "status": "ok" if util <= 1 else "fail"},
        ],
        "calcLines": [
            {"standard": "AS/NZS 4600", "year": "2018", "clause": "3.3.3", "label": "Member moment capacity",
             "eq": "phi Mb = %.2f kN.m" % phi_mb, "tex": r"\phi M_b = %.2f\,\mathrm{kN.m}" % phi_mb},
        ],
    }

result rows are { label, value, unit?, status? } with status one of ok, warn, fail. calcLines rows carry standard, year, clause, label, eq (plain text) and tex (KaTeX). They render like the host's own working and go into the calc package.

Unset inputs. check runs once on open with the form defaults, so an enum whose default is "" will not match a member. Return a row such as { "label": "Status", "value": "Select a member", "status": "warn" } and no calc lines rather than throwing. An exception reaches the user as an error banner.

The form tree ​

Node types: panel, tabs / tab, section (title), row (cols: [1, 2]), field, button, text (value), table (columns: [{key,label}], rows), result (items), calc (lines), chart (bar, line, scatter; contract below), badge.

Field inputType: number, integer, boolean, string, enum (options: [{value,label}]). Numbers may carry step, min, max, and a unit naming a host quantity for the SI/imperial display toggle: length, lengthLong, area, inertia, modulus, warping, mass, massPerLength, force, moment, momentPerLength, areaPerLength, stress, pressure, subgradeStiffness. A field with a unit stores SI (m, N, N·m, Pa) and converts for display only; a field without one is a plain number in whatever unit its label says.

Capabilities ​

What civilkit.<name>() offers. Everything returns a JSON string; json.loads it.

CapabilityReturnsNotes
getModel()nodes[{id,x,y,z,restraint_flags}], members[{id,num,node_a,node_b,section,section_id,member_type,beta}], sections[{id,section_name,a,izz,iyy,j,fy}] (SI), materials[{id,e,g,rho,fy}] (Pa), load_cases, member_loads, nodal_loadsRead-only snapshot. id is a string, num the engineer-facing number, section the catalogue name. Fixture-scoped in a test.
getSectionProps(name)depth_mm, flangeWidth_mm, webThickness_mm, flangeThickness_mm, area_mm2, Ix_mm4, Iy_mm4, Zx_mm3, Zy_mm3, Sx_mm3, Sy_mm3, J_mm4, Iw_mm6, massPerMetre_kgm, category, fy_MPa, fy_sourceAU catalogue names, spelling exact (below). Model-defined sections fall back to their solver fields. Unknown name throws.
getSelection(){ type, id } or nullWhat the user has selected.
memberRoles()[{ id, num, role }]Structural role by geometry, in this order of rules: vertical on a frame line is column (mullion on a gable line, prop at mid-span); along the span at or above the eave on a frame line is rafter; along the length at the eave on the outer walls is eaveStrut, above the eave purlin (ridgeStrut on the ridge line), below the eave girt; across the span on a gable below the eave is endGirt; rise and run is brace; the rest strut. Roles: column, rafter, purlin, girt, endGirt, eaveStrut, ridgeStrut, mullion, prop, brace, strut, or null. The same derivation add_member_load selects by; roles are derived, never stored. Works on a fixture.
hasResults()boolWhether the model has been solved with design checks. Always false during a worked example.
memberChecks()[{ memberId, num, id, section, length_m, gov, govBy, status, code, capacitySource }]The engine's governing AS 4100 / AS/NZS 4600 check per member, for the active load case or combination (the host's set_active_case, including "envelope"). capacitySource is "case" when the row is the active case's own (its own alpha_m) and "union" when it came from another case, which is what a combination gets. The same rows the REST tool get_member_checks returns. utilisation and governing remain as aliases of gov and govBy. Empty during a worked example.
memberDerivation(ref){ code, lines[{clause,label,eq,tex}], note }The clause-by-clause working for one member, as the Design panel shows it. ref is the num or the string id. Unavailable during a worked example, with a note saying so.
selectMember(ref)Highlights a member in the viewport. ref as above.
log(msg)Console. Free: never needs declaring.

Yield strength. The catalogue carries geometry, not grade, so fy_MPa is whatever the engine will use for that section: the model section's own fy when the section is in the model (fy_source: "section"), else the model's first material ("material"), else null. Guard for null and say in a calc line where fy came from. Do not type a grade table into the module; a project's grade belongs on the model's section or material.

Finding a section. The REST tools list_sections {family, match} and get_section_props {name} search the catalogue and return the same properties the capability does, so a name can be checked before it goes in a model or a fixture. category is one of: Universal Beam, Universal Column, Channel, Square Hollow Section, Rectangular Hollow Section, Circular Hollow Section, Equal Angle, Unequal Angle, Flat Bar, Cold-Formed Channel (C), Cold-Formed Zed (Z). For a CHS, depth_mm is the outside diameter and webThickness_mm the wall. Round bars for rod bracing are ROD12 to ROD36 (category Round Bar, depth_mm the diameter); a rod brace built by add_bracing is tension-only by default. The member check uses the gross area; the threaded-end tensile stress area is the end connection's business, not the member's.

Catalogue names, one per family: 250UB25.7, 310UC96.8, 380 PFC, 65x65x4 SHS, 100x50x4 RHS, 21.3*2.6 CHS, 200*26 EA, 150*100*12 UA, FL 50x6, ROD16, cold-formed C20019, Z20019. Lookup is case-insensitive; the spaces and separators are not optional.

Two identifiers. A member has a string id (the document's key; use it to look members up in getModel) and an integer num (the engineer-facing number the solver and the Design panel show). Every memberChecks row carries both, getModel members carry both, and memberDerivation and selectMember accept either. In a generated model the two usually print the same; in an imported one they need not.

member_type on a getModel member: 0 beam, 1 truss (axial only), 2 cable, 3 spring, 4 tension-only, 5 compression-only. A rod or CHS X-brace should be 4.

Charts. { "type": "chart", "chart": "bar", "id": "util", "title": "...", "bars": [{ "id": <member ref>, "label": "...", "sub": "...", "value": 0.83, "valueLabel": "0.83", "tone": "ok"|"warn"|"fail" }], "threshold": 0.85, "thresholdLabel": "firm cap", "max": 1.2 } draws a ranked horizontal bar chart the host owns. A click on a bar reaches check as nothing; it reaches the host as a chartclick event carrying the bar's id, and the host calls selectMember with it, so a bar whose id is a member ref highlights that member in the viewport. chart: "line" and "scatter" take series: [{ label, points: [[x, y], ...] }]. Tables have no click hook.

The rule: numbers come from capabilities. A capacity formula typed into main.py is the thing the Verified badge cannot vouch for.

Worked examples and the Verified badge ​

tests/worked-examples.json is a list. Each example runs check(input) against its own model fixture and compares expected.result with what came back, row by label: numbers within 0.1 %, strings exactly, a stated unit exactly. Extra rows in the result are ignored. source says where the numbers come from; make it a clause and a hand calc.

json
[{
  "id": "250ub-40kNm",
  "input": { "member_id": "m1", "m_star": 40.0 },
  "model": {
    "nodes": [{ "id": "n1", "x": 0, "y": 0, "z": 0 }, { "id": "n2", "x": 6, "y": 0, "z": 0 }],
    "members": [{ "id": "m1", "node_a": "n1", "node_b": "n2", "section": "250UB25.7" }],
    "materials": [{ "id": 1, "fy": 320e6 }]
  },
  "expected": { "result": [{ "label": "phiMs", "value": 82.08, "unit": "kN.m" }, { "label": "Status", "value": "OK" }] },
  "source": "AS 4100 Cl 5.2.1: phi Ms = 0.9 x 320 x 285e3 / 1e6 = 82.08 kN.m; M* = 40 gives 0.487."
}]

The fixture is in the module-facing shape, which is not the document schema load_model takes; the host translates the document into it for getModel, and a fixture is written directly in it: string ids, section by catalogue name, materials[].fy in pascals. During an example the fixture is the whole world for getModel, memberRoles and the model half of getSectionProps; the catalogue half of getSectionProps is the real catalogue; hasResults is false, memberChecks is empty and memberDerivation says it is unavailable.

Put differently: getModel returns it and hasResults is false, so a module that reads solved results can only prove its no-results path in tests. That is by design. Its numbers are the engine's, verified by the engine's own suite, and a badge earned by reproducing whatever model happened to be open would mean nothing.

The badge is decided at install, in the app and headless alike:

  • Verified: every worked example reproduced its cited numbers on its own fixture.
  • Community: anything else, including no tests.

Verified is a test gate, not a review. A person decides whether the cited examples are the right ones before a module goes into a firm's library.

Testing without the app ​

One call, through MCP, the CLI or REST:

run_extension { "path": "my-module.ckext" }                        MCP / CLI
POST /v1/tools/run_extension { "base64": "<zip bytes>" }          REST

It installs the bundle into the session and returns everything the author needs to fix it: the badge; every worked example with per-row diffs (phiMs: expected 82.08, got 76.95); the form's fields and buttons; one check run on the first example's inputs and fixture, or on inputs you pass against the live model; the capabilities used versus declared; and any capability the module called without declaring, named. list_extensions shows what the session has installed.

The loop, for a person or an agent:

  1. Write the three files, with at least one worked example and a cited hand calc.
  2. Zip, run run_extension, read diffs and refusals.
  3. Fix, repeat until the badge is Verified and refusals is empty. capabilities.unusedInThisRun is per call: the default run is on a fixture, never solved, so result-reading capabilities show unused there and the response's note says so. Solve a model and call again with inputs to see them used.
  4. Install it in Studio from Tools → Modules and hand it to a reviewer.

Reference modules ship in the store under Tools → Modules; member-shear-check is the shortest complete one and model-code-audit reads memberChecks and memberDerivation. Their sources are in the repository under experiments/py-module/examples/, and the same page in repository form is docs/reference/extensions.md.