add-bpm-feature
Step-by-step house pattern for adding a new BPM operations tool, widget tool, or widget to the engine module (`packages/connectors/engine/engine-connector`, over `packages/connectors/c7/c7-client` and `packages/connectors/c7/c7-core`). Use whenever adding or changing Camunda 7 / CIB seven tools ("add a tool to suspend jobs", "expose X from the engine REST API"), input schemas, cockpit routes, `*_data` feeds, or React widgets in the cockpit. Takes precedence over generic MCP SDK examples.
- License 许可
- MIT
- 所属仓库
- Miragon/pulse
- 最近更新
- 2026-11-14
- Stars
- ★ 8
- Forks
- 0
01 前言 Front Matter
- name
- add-bpm-feature
- description
- Step-by-step house pattern for adding a new BPM operations tool, widget tool, or widget to the engine module (`packages/connectors/engine/engine-connector`, over `packages/connectors/c7/c7-client` and `packages/connectors/c7/c7-core`). Use whenever adding or changing Camunda 7 / CIB seven tools ("add a tool to suspend jobs", "expose X from the engine REST API"), input schemas, cockpit routes, `*_data` feeds, or React widgets in the cockpit. Takes precedence over generic MCP SDK examples.
- allowed-tools
- Read, Edit, Write, Glob, Grep, Bash
03 SKILL.md 正文
add-bpm-feature: new tool/widget in the engine module
The engine module never registers operations tools with a raw server.registerTool(). Tools go
through the registrar (createToolRegistrar from @miragon/pulse-widget-shell/server);
engine access goes through withEngine/resolveEngine (per-call engine override >
ENGINE_DEFAULT > the profile file's defaultProfile > the only profile, refused ones counted;
a refused target fails with ENGINE_PROFILE_REFUSED, CLAUDE.md invariant 2). A view is
registered in three places (CLAUDE.md invariant 3). Follow the steps for the path you need.
Step 0: pick the render path
| You need… | Path |
|---|---|
| JSON data for the model | Registrar tool in src/tools/<domain>.ts (Steps 1–3) |
| A view for the user | a cockpit route plus its builder and *_data feed (Step 4); never a second view tool |
| App-only JSON for in-widget refresh/nav | *_data feed in DATA_FEEDS of src/data/feeds.ts (Step 4) |
| An answer the CLI's workflow commands give too (inspect, status) | a composite on @miragon/pulse-c7-core over src/lib/core-adapter/ (Step 2b) |
Step 1: input schema next to the tools
Add the Zod input schema to packages/connectors/engine/engine-connector/src/schemas/<domain>.ts and
export it from src/schemas/index.ts. Existing style (from schemas/process-instances.ts):
export const listProcessInstancesInput = z.object({
processDefinitionKey: z.string().optional(),
businessKeyLike: likeParam(),
active: flagParam(),
withIncidents: flagParam("true = only instances with an open incident"),
firstResult: firstResultParam,
maxResults: maxResultsParam(), // capped at MAX_PAGE_SIZE (100); default page 20
sortBy: z.enum(["instanceId", "definitionKey"]).optional(),
sortOrder: sortOrderParam,
})
Descriptions. Describe a field only where the tool gives it its own meaning: what false
or an omitted value selects when no shared rule says it (withIncidents: open only), a unit or
format the name does not say (retryTimeout: in milliseconds), an id that names another object
than its name suggests, or which views take it (engine_open_cockpit). A description that
restates the name or the tool description goes. What a parameter means on every tool
(engine, paging and sorting, processDefinitionKey/processDefinitionId, activityId,
incidentType, …Like, the boolean filters, the date bounds, the variable shape) is said once
in engineInstructions (packages/connectors/engine/engine-connector/src/instructions.ts),
and the paging, sorting, …Like, flag, date and variable-map helpers in schemas/shared.ts
carry no description of their own, so a field built from them needs none (flagParam and
engineDateParam take one where the field means more; wholeVariableParam keeps its own). A
new shared meaning goes into the instructions, briefly, not onto each tool. A safety rule stays
at the write it guards (the cut-value rule on the variable writes), where a host that drops the
server instructions still shows it. apps/mcp-server/test/tools-list.golden.test.ts
fails when one description repeats on more than two model-visible tools.
A list/query tool pages with the shared params from
schemas/shared.ts (firstResultParam + maxResultsParam(defaultPageSize)), never a
hand-rolled maxResults (a *_data feed spreads pagingShape from the same file, the one paging
definition in its described, default-free form): src/tools/list-tools.test.ts treats every registrar tool that
takes maxResults as a list and fails it without the cap or the envelope (Step 2). The
REST calls themselves use the generated SDK (imported from
@miragon/pulse-c7-client/sdk). If the endpoint is missing there, the OpenAPI spec
changed and you need pnpm generate, not a hand-written fetch. The engine's REST rules the
handler applies (toEngineDate, engineLike, complementaryFlags, engineSorting,
toEngineVariables, incidentRecovery, RAW_VARIABLES, isMissing) come from
@miragon/pulse-c7-core, their one home, which the CLI uses too; the reads bound to the SDK
(fetchJobStacktrace, readProcessInstanceVariables, readBatch) from
@miragon/pulse-c7-client. Those reads throw every 404: a handler that reads a missing
resource as "none" wraps the read in noneIfMissing (src/lib/core-adapter/engine-port.ts,
c7-core's isMissing), so a wrong REST root still fails loud.
Parameter naming guide
Inputs are STRICT (the registrar is always strict; raw server.registerTool() registrations use
strictToolInput(...) from @miragon/pulse-widget-shell/server, and the boot refuses any other
input schema that lets an unknown key through): an unknown key is a tool error listing the valid keys. So one name per concept across every tool: a model that learned
assignee on one tool must not be refused it on the next. Renames ship without aliases.
| Concept | Name |
|---|---|
| Process definition | processDefinitionKey (all versions), processDefinitionId (one version); never bare key |
| Entity ids | <entity>Id: processInstanceId, deploymentId, jobId, activityId; never bare id, never elementId |
| Several values | <name>In, an array (processDefinitionKeyIn); merged with the single form via engineKeyList |
| User filter | assignee (the user a task is assigned to); a write that sets a user takes userId |
| Business key / substring | businessKey exact, businessKeyLike/nameLike substring via likeParam(): engineLike wraps a %-less value |
| Time bounds | <event>After/<event>Before (startedAfter, finishedBefore, incidentTimestampAfter) via engineDateParam(): ISO 8601 in, toEngineDate out |
| Boolean filters | flagParam() + complementaryFlags (active/suspended, …) or trueOnly: the engine IGNORES false (never sent); a both/neither pair is refused; a new complementaryFlags pair is named in engineInstructions (instructions.test.ts checks it) |
| Paging / result size | firstResult + maxResults from firstResultParam + maxResultsParam (above; also for a top-N cap: never limit) |
| Sorting | sortBy + sortOrder, sent as a pair by engineSorting |
The engine's own query names may differ (taskAssignee, processInstanceBusinessKey,
withIncident): the handler maps them, the schema keeps the guide's name. Every
filter a tool advertises is pinned on the wire in src/tools/filters.wire.test.ts
(page query AND /count); a retired spelling (key, id, limit, elementId, …) on
any module tool fails apps/mcp-server/test/strict-input.e2e.test.ts, so a parameter that
instructions or a description quote but the tool does not take turns into a refused call.
The one quoted filter set in the instructions (the retry playbook's engine_list_jobs
filters) is held to listJobsInput by src/instructions.test.ts; a newly quoted parameter
gets its own pin there. Widget Ask-AI hand-offs and model contexts never quote parameters:
they pass typed ids (see Step 4).
Step 2: register the tool
Add the tool in packages/connectors/engine/engine-connector/src/tools/<domain>.ts. The reference template is
engine_list_process_instances from src/tools/process-instances.ts:
import { listProcessInstancesInput } from "../schemas/index.js"
import type { ToolRegistrar } from "@miragon/pulse-widget-shell/server"
import { complementaryFlags, engineLike, engineSorting } from "@miragon/pulse-c7-core"
import { getProcessInstances, getProcessInstancesCount } from "@miragon/pulse-c7-client/sdk"
import { paginatedListOutput, toPaginatedList } from "../lib/mcp/pagination.js"
import type { EngineRegistry } from "../lib/engine/resolve-engine.js"
import { engineParamShape, withEngine } from "../lib/engine/with-engine.js"
export function registerProcessInstanceTools(register: ToolRegistrar<EngineRegistry>) {
register({
name: "engine_list_process_instances",
category: "process-instances",
description: "List running process instances.",
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
inputSchema: { ...listProcessInstancesInput.shape, ...engineParamShape },
outputSchema: paginatedListOutput,
handler: withEngine(async (client, args) => {
// The filters in the engine's format (naming guide), shared by the page
// and its /count twin (fetched in parallel), so the envelope's total is honest.
const filters = {
processDefinitionKey: args.processDefinitionKey,
businessKeyLike: engineLike(args.businessKeyLike),
...complementaryFlags(args, "active", "suspended"),
}
const [items, count] = await Promise.all([
getProcessInstances({
client,
query: {
...filters,
firstResult: args.firstResult,
maxResults: args.maxResults,
...engineSorting(args),
},
}),
getProcessInstancesCount({ client, query: filters }),
])
return toPaginatedList(items, count, args.firstResult)
}),
})
}
A list tool's description says what it lists, never the paging contract
(src/tools/list-tools.test.ts).
Non-negotiables:
- Every tool carries a
categorymatching its domain file name (e.g."process-instances","tasks";task-form.tsuses"tasks",incident-issue.tsuses"incidents"). inputSchemaspreads...engineParamShapeso every tool accepts the per-callengineoverride.handleris wrapped inwithEngine(...). Never construct, cache, or import a client directly;withEngineresolves the engine from the registry per call.- Tool names are
engine_<verb>_<noun>in snake_case. - Write tools that only flip state return a small
{ success: true, … }object instead of the raw (often empty) REST response. - Destructive/admin-grade tools (delete, modify, suspension, deployments, migrations,
batches, engine-wide broadcasts like signals, the external-task worker protocol) and
reads that only feed an admin write (
engine_get_batch,engine_get_activity_instance_tree) must be added toADMIN_ONLY_TOOLSinsrc/lib/mcp/toolsets.tsso theengine:read-only|operations|admintoolset filtering stays correct.read-onlymembership is derived fromreadOnlyHint: true.src/lib/mcp/toolsets.test.tsenforces the rule structurally over every registrar tool (destructiveHint⇒ admin-only, read-only ⇒readOnlyHint, every write statesdestructiveHintexplicitly), andsrc/plugin.test.tsover every tool the module registers, feeds included (the read-only floor holds onlyreadOnlyHinttools); get the annotations right, never edit the test to pass. - Toolsets fail CLOSED: with no suffix a deployment runs
read-only(no OAuth) oroperations(OAuth);adminis only ever reached by naming it. So anoperationstool is on for every authenticated default deployment and anadmintool for none: place a new write deliberately. A tool that runs code inside the engine (engine_create_deployment) additionally sits behind its own strict env opt-in (ENGINE_ALLOW_DEPLOYMENTS=true) and is not registered without it; follow that pattern for anything comparable instead of trusting the toolset alone.
Annotation conventions
| Operation | Annotations |
|---|---|
| Read / list / get | { readOnlyHint: true, idempotentHint: true, openWorldHint: true } |
| Set-style write (variable, retries, assignee) | { destructiveHint: false, idempotentHint: true, openWorldHint: true } |
| Other write (start, complete, correlate, …) | { destructiveHint: false, openWorldHint: true } |
| Delete / irreversible (delete, modify) | { destructiveHint: true, openWorldHint: true } (admin-only) |
| Engine-wide / worker / deploy | { destructiveHint: true, openWorldHint: true } (admin-only) |
MCP reads an ABSENT destructiveHint as true, so every write states it; false is a
deliberate claim, not a default. idempotentHint: true marks writes that repeat to the
same state. Every engine tool carries openWorldHint: true (it talks to an external
engine); a tool that only reads the server's own config (engine_list_engines) says
openWorldHint: false.
Step 2b: a read composite on c7-core
A question the CLI answers too (where an instance waits, how an engine is doing) runs on
@miragon/pulse-c7-core (packages/connectors/c7/c7-core, the core of the Camunda 7 REST
dialect family c7, which the engine module spans), the workflow domain both share, never on a
second copy here. A rule both need that it
lacks goes into that package (a leaf: no dependency, data rather than wording).
The pattern is engine_inspect_process_instance and engine_status
(src/lib/core-adapter/composites.ts, registered like any registrar tool):
- Map nothing by hand: the port (
src/lib/core-adapter/engine-port.ts) sends every operation the composite asks for as its route of c7-core'sCORE_ROUTES(routedTransport) through the engine's client, so a composite that needs an operation c7-core does not send yet adds it to c7-core'sCORE_OPERATIONS, andsrc/lib/core-adapter/engine-port.test.tsholds every operation of c7-core's lists to a reading route. - Word c7-core's error reasons in
src/lib/core-adapter/workflow-errors.ts([CODE] messagein the tool's parameter names, no tool names). - Return data for the model: drop
next(it would name tools a toolset may lack), cut variables withtruncatePlainVariables, and cap every list c7-core leaves uncapped for the CLI (today wait states, incidents, timeline, variables, definitions, topics), naming each cut intruncated. The cap bounds the result, not the reads. Pin the wire and each cap insrc/lib/core-adapter/composites.wire.test.ts(inspect) andsrc/lib/core-adapter/composites-status.wire.test.ts(status), over the helpers ofsrc/lib/core-adapter/composites.test-support.ts. - How an engine is doing has one answer: mask incidents with c7-core's
messageSignature, count one cause with itscauseKeyOf/largestCauseand judge with itsengineVerdict(ok | warning | critical) by theENGINE_HEALTH_*thresholds (pulse status: the defaults), asengine_statusand the cockpit's health route (src/data/health-data.ts) do. Neither text names the other's verdict.
src/lib/ is split by job, and each helper goes into the folder of its job: engine/
(routing: resolveEngine, withEngine, the cockpit links, passthrough auth), core-adapter/
(the c7-core port, the composites, their error wording), bpmn/ (the definition XML,
BPMN and task-form reading, completing a user task by its form), mcp/ (the tool surface:
toolsets, the paging envelope, the bounds on variable values and failure text) and shared/
(pure code the widgets run too). src/data/ and src/lib/ never import src/tools/,
src/widget-tools/, src/widgets/ or the connector's root (index, plugin, module)
(connector-data-and-lib-point-down): a helper a builder needs from a tool moves down into the
lib folder of its job (the task form's buildTaskFormSchema into bpmn/).
Step 3: wire a new domain file
Only when you created a new src/tools/<domain>.ts: add
registerYourDomainTools(register) to registerTools in
packages/connectors/engine/engine-connector/src/tools/index.ts, mirroring the existing calls.
Step 4: widget path (only for UI features)
Widget components live in packages/connectors/engine/engine-connector/src/widgets/ and receive their data as
a data prop. Compose them from the design kit @miragon/pulse-ui and the view runtime
@miragon/pulse-widget-shell/widgets (pulse-ui holds what touches no host: primitives,
components, tone maps, cn, the formatters, Icon, the table cells; widget-shell holds what
reads the host or calls a tool, and neither re-exports the other's names); never re-inline
these primitives. Widgets are browser code: at runtime they import only their own widgets,
src/messages, src/tool-names.ts, the view models, @miragon/pulse-widget-shell/widgets and the
view stack, plus src/lib/shared (pure code the server runs too, held to the same allowlist);
src/tools, src/data, the feed contracts, the plugin, the module,
@miragon/pulse-c7-client and anything else only as types (import type), and the other
src/lib folders not even as types: a type both sides need is declared once in
src/lib/shared (TaskCompletion). Data arrives through the view tool and the *_data feeds
(connector-widgets-are-browser-code and connector-widgets-only-shared-lib in
.dependency-cruiser.cjs):
ViewDataStateis the loading/error/no-data guard (no inline Alert/loading ternary):onRetryis required (paged.retry, a query'srefetch), though a refusal no retry changes (isPermanentError: a refused profile, a read-only engine) shows no Try again, and the view's query client never retries one; loading is askeletonslot under a polite sr-only status ("Ansicht wird geladen…" unlessloadingText), no data pulse-ui'sEmptyState- Self-fetching widgets: skeleton + error via
QueryFallback(+TableSkeleton). A missingisErrorbranch means an eternal skeleton;onRetryis required - Errors speak through widget-shell's
describeError(error, locale): an engine or transport failure (503/unreachable, timeout, a tool this server lacks, 401/403, 404, an engine exception) becomes what happened ("Die Engine prod ist nicht erreichbar (HTTP 503)") and what you can do, the raw "[503 ProcessEngineException] …" collapsed under the "Details" of pulse-ui'sErrorState: never raw engine text as a headline or in a toast.ViewDataState,QueryFallback,useDetailView(its stale notice: an inline warningErrorState),ConfirmDialog,useActionFeedbackand the view's own failed call (ViewFrame) all use it; a widget's own error line does too (ErrorStatewithdescribeError(...)), anderrorTitleheads only a failuredescribeErrorcannot classify - A view that reads its feed directly calls widget-shell's
useViewData(from@miragon/pulse-widget-shell/widgets): the handed-in tool result always seeds a live query, so Refresh and a write's invalidation re-read it (#341). Widgets import every query hook (useToolQuery,useToolQueries,useToolMutation,useViewData,useQueryClient) and the host seam (useViewHost,HostModelContext) from the view runtime, never from@modelcontextprotocol/ext-apps(ESLint); tests render them underTestViewHost, whoselocale(else an outerLocaleProvider's, else English) andhost.timeZonethe formatters render in, asLocaleGatepublishes the host's: a widget test sets nosetFormatLocaleof its own and reads the same on every machine - Across engines (the cross-engine overview) a widget reads each engine's feed under the key
its other readers use (
useToolQueries, one read per engine), and a figure over engines is a sum of counts (foldFleetLandscapeinsrc/lib/shared/fleet-landscape.ts), never a rate, a duration or a ranking: engines run different processes formatTimestamp/formatDate/formatTime/formatClock/formatInstant/formatRelativeTime(dates) andformatNumber/formatCount/formatCompactCount/formatPercent/formatPercentPoints/formatPeriod/formatDuration/shortId/truncate, all from@miragon/pulse-ui(the same set the VS Code extension reads from@miragon/pulse-ui/format): no local format helpers, notoFixed, notoLocaleString()without a locale, no hand-builtIntl.NumberFormat(canonical duration style "3m 7s", "2d 3h" / German "3 Min. 7 s"; a lower bound "≥ 2,000", compact "10k+"; "3,7 %", "+0,2 Pp.", "7 Tage")- Tables: widget-shell's
ListTableis the frame: a named, keyboard-reachable scroll region (role="region",tabIndex=0;ariaLabelis required), so a wide table never widens the document at 760 px (the host simulation pins it). A column a narrow view can do without takeshideBelow: "sm" | "md" | "lg"(the REGION's width: 640/768/1024 px;mdhides inline, shows in fullscreen), the row actions are the LAST column withsticky: true(pinned at the region's right edge, Retry never scrolled away). Rows stay hand-composed<tr>+Td, one cell per column (the rules address cell positions). An id column isIdCell(IdTextinline): Geist Mono, shortened ("5f0ce78e…", whole for screen readers and intitle), a copy button with a toast. Nothing to show: pulse-ui'sTableEmptyState,variant="done"(CircleCheck, success) for "nothing left to do" ("Keine offenen Incidents"), the defaultempty(quietInbox) for no data;description,action. The Komet (KometMark,AppEmptyState) only on APP-level surfaces (landing, first start, "no engines"), never in a widget - Type roles (pulse-ui's
TYPE_SCALE): a widget's page title is theWidgetHeader(28/700, an h2 below the view,as), a sectiontext-section(21/600), running texttext-body(16), dense tables and controlstext-dense(14), captions and metatext-small(13, the floor); notext-xs/text-[11px], no Tailwind sizes in kit code (scanTypeScalefrom@miragon/pulse-ui/testing; connectors adopt it withrules: ["below-floor"]first) - From pulse-ui:
Section(collapsibles);Th/Td(cells);WidgetHeader(badge/titleSuffix/size="detail"/as, no icon tile) +VersionChip(hero/detail headers);KpiGrid(KPI strips, incl.variant="soft"; a clickable cell says what the click does inactionHint, neverariaLabel). From the view runtime:WidgetShell(page container, split into View + Shell only when the body is embedded elsewhere) - Where and what (#322 U4, U13): the cockpit's head names the engine and its environment (a
text chip, "PROD"), so a route view's
WidgetHeadersubcarries only the time it read (widget-shell'sViewMetawithasOf) and its own facts; a route widget takesengineand its ids as required props and builds its feed args from them alone. A write confirmation addsengineTargetLines(t, engineId, useEngineEnvironment(engineId))(widgets/lib/engine-context.tsx). Names before ids: the design kit'sNamedId(@miragon/pulse-ui: an activity's or process's name, the id a quiet mono line; the same look in every module),InstanceLabel(widgets/lib/identity.tsx: the business key, the UUID a copyableIdTextchip; the words ofinstanceName); a drill passes the target'slabelso its breadcrumb reads the name. The builder reads names as enrichment (fetchNamesByDefinitionwithprocessNameIn/elementNameIn,fetchDefinitionsById,fetchBusinessKeys), every diagram name through widget-shell'sreadBpmnNames/parseBpmnNames(never a regex of your own: every view reads the same diagrams), and a view that shows the engine's definition name passes its rows throughnameUnnamedDefinitions(a Modeler pool around an unnamed process names it, as in every view). A row keeps ONE deterministic action; its vendor link and chat hand-offs sit behindMoreActions, asvariant="menuitem"entries (HandOffButton,OpenInCockpitLink); a confirmation names an instance withinstanceTargetLines(business key, then short id) - Status: a
ToneVariant(danger/warning/success/info/neutral; open incident or failed job without retries =danger(severityTone/countTonefrom@miragon/pulse-uiis the one ladder for incident and job counts in every module, a count without a state stays neutral; an instance's or a step's state takes its tone fromSITUATION_TONES, the table the VS Code webview reads too: runninginfo, suspended and endedneutral), retries left or degraded =warning; an engine's own state is its verdict's tone,VERDICT_TONE, never a ladder over its counts) shown via pulse-ui'sStatusBadge(tone and words required)/CountPill/KpiCell.toneor itsTONE_DOT/TONE_BORDER/TONE_ICON/TONE_TINT/TONE_SOFT/TONE_INKmaps (a tone from the wire throughresolveTone), next to readable words; never a coloured number, never a palette class or raw colour; text on a tint is black (TONE_SOFT), the-inkrole is only for text in a tone directly on the card or page (an inline error) - Icons: Lucide via
Icon(import { FileSearch } from "lucide-react"; the package pins devDependencylucide-react1.70.0, see CLAUDE.md invariant 6), never a Unicode glyph or emoji, at 16 px (densein rows;scanIconSizesallows 16 and 24 only); a chat hand-off is widget-shell's<HandOffButton action="explainError" prompt={ask(…)} />(@miragon/pulse-widget-shell/widgets: the product's one table of functions, each with its Lucide icon and a label that names the chat, "Fehler im Chat erklären" / "Explain error in chat", the same in every module;AskAiButtonis banned in connector widgets, so a new function gets its entry and both labels in widget-shell), placed after the deterministic action;OpenInCockpitLinkgetsvendor={data.engineVendor}(the builder sets it fromprovider.branding.displayName) - Copy: en + de catalogs in the brand voice (du, AI, "CIB seven", no dash connector,
errors say what happened and what you can do,
Ja/Neinfor Booleans) and the terms ofsrc/messages/GLOSSARY.md(a count the VS Code webview shows too takes its word fromCOUNT_TERMS); counts go in formatted (formatNumber) and the entry picks the noun's number (countOffrommessages/plural.ts). The gates:catalogTextFindingsover every catalog and en/de key parity of the widget catalogs (messages/catalog-text.test.ts), the glossary (messages/glossary.test.ts),scanGlyphsover all ofsrcandscanColorsoversrc/widgets(src/brand-gates.test.ts; the gates, the glossary and the count terms come from@miragon/pulse-ui/testing) - A pair: the overview, a process, its instances, an instance, an incident and the history
have a counterpart in the VS Code webview, which shares only pulse-ui's foundation with the
widgets. A field, a count or an action you add to one says in its pull request whether the
webview gets it and updates
docs/vscode/parity.md; its content takes props and callbacks only, no host context (asprocess-definitions-table-view.tsxwithrenderActions) - BPMN: viewer lifecycle and the fixed light canvas via
useBpmnViewer(the container is a forced-light island,data-theme-mode="light", on the paper token) +BpmnZoomControls(light in every theme, Lucide). The frame height comes from the diagram's size (heightfrom the hook: its height at the scale bpmn-js draws it, never past 1:1, 240–560 px, read from the XML's DI before the import, so the frame does not jump): pass no fixedheighttoBpmnDiagramunless a layout needs one. The process flow draws only engine reads: the open incidents, or as "Running" theinstance-count,incidentandfailed-jobsbadges ofengine_activity_statistics_data(every version summed onto the latest diagram, mounted only once picked), or as "Throughput" its history source as heat (BpmnHeatmap, the rampBPMN_TOKENS.heatread from the theme's variables; a read that loads the engine's database: see CLAUDE.md invariant 7). Overlay and legend colours are widget-shell's ONE token module (BPMN_TOKENS,widget-shell/src/ui/bpmn-tokens.ts: incident = danger, running/open task = blue on the info tint, counts white on blue or danger; strokes ≥ 3:1, badge text ≥ 4.5:1, drift- and contrast-tested) throughHIGHLIGHT_COLORS(src/widgets/bpmn-highlights.ts); the CSS isinjectBpmnOverlayCss(). No pulse: an open user task is a heavier dashed stroke
Paged list recipe (every list that can exceed one page follows it; reference:
src/widgets/process-instances/list.tsx):
- Feed: accept
firstResult/maxResults(+ server-side filter params likebusinessKeyLike/nameLike) and return the full filtered total (via the matching/countendpoint) so the footer is honest. - Widget:
usePagedListView(owns search state, 300 ms debounce, the server-sidesearchArg, and the drop-initialData-on-interaction rule; chips go intofiltersActive+ theargs) →FilterBar(withsearchAriaLabel, the placeholder's words without the ellipsis, andchipsAriaLabelfor a chip row: pulse-ui's default name is a generic "Filter";src/widgets/search-labels.test.tsxpins every search box by its name) →PagedRows(marks the rows while they are the previous result) aroundListTable(frame +Thheader row; rows stay hand-composed<tr>+Td) or aRowCardstack →TableEmptyState(distinguish "no match" wheninteracted) → pulse-ui'sPagedListFooter(pagedas is,nounfrom the catalog; it renders the stale page-0 error and the load-more error, each with its own retry and the raw reason under Details, + "Showing X of Y" in the list's one live region + Load more; deliberately not infinite scroll; rendered under an empty result too). Guard the whole view on!paged.firstPageonly: a search keeps the previous rows (and the search box) mounted while its page 0 is in flight. While it does,paged.staleis true andfirstPage/totalare the PREVIOUS result's: a header count, the filters it covers and the model context read the payload's echo (data.filters), never the request's args (reference: the instance list'slistFilters).
The engine module has ONE view, engine_open_cockpit → CockpitApp (CLAUDE.md invariant 3). New
UI is a cockpit route, never a second view tool. On the server a route is five entries;
COCKPIT_ROUTES and the widgets' route switch fail to compile without it, and
src/data/cockpit-start.test.ts fails on a route whose feed is missing from DATA_FEEDS:
src/view-models.ts: a section inCockpitViewwith the ids the route reads, and its data type inCockpitStartData.src/data/: ONE builder for the route's data (<route>-data.ts).src/data/feeds.ts: the route's*_datafeed,dataFeed({ name, title, description, shape, read }), inDATA_FEEDS;readcalls the builder.src/widget-tools/data-feeds.tsregisters every feed ofDATA_FEEDS.src/tool-names.ts: the feed's name as anENGINE_*_DATAconstant.src/data/routes.ts: the route's entry inCOCKPIT_ROUTES: the ids itneedsand alsotakes(fields of its ownCockpitViewsection, checked at compile time), itsstart(its feed's read with the first page's args) and itsfacts.src/widget-tools/cockpit.tsbuilds theviewenum and checks the ids from it, andsrc/data/cockpit-start.tsdispatches the start view and its facts. A start param new to the cockpit also gets its described field instartViewShape(src/widget-tools/cockpit.ts;StartParamis read offCockpitView, so a missing field fails to compile), and a route that takes an existing param adds its name to that param's description (the tools/list golden shows the change).
In the widgets a route is its widget, a case in CockpitRoute
(src/widgets/cockpit-app/routes.tsx) that renders it with seedOf(seed, section) as its
data and the route's engine and ids as required props, a NavIntent and an emitter (below)
and its en/de messages. A composed view (a dashboard, the process detail) is ONE component that
composes its widgets in a ViewStack.
The start data is exactly the feed's page-0 payload: build it with the size the widget
pages by (LIST_PAGE_SIZE, HISTORY_PAGE_SIZE in src/view-models.ts), so the route's
widget seeds its feed with it and refreshes after a write without a fetch on mount
(src/data/cockpit-start.test.ts pins the seed against the feed). The facts are the
model-facing result: compact JSON, English keys, ids and counts (a null count is
unknown, never 0), engine text clipped, never XML. A route needs an emitter in the cockpit
too: a DrillButton, a KPI cell's onClick, or a head section in
src/widgets/cockpit-app/app.tsx, each through useNav() with a NavIntent
(src/widgets/navigation.ts) that intentToView (src/widgets/nav-core.ts) maps onto the
route. A route that nothing emits and engine_open_cockpit cannot open is dead.
apps/mcp-server/test/view-registry.test.ts holds the view registry
(apps/mcp-server/src/ui/view-registry.ts, which spreads engineViews from
src/widgets/index.ts) to the view-bound tools of the tools/list goldens.
src/widget-tools.ts wires the cockpit tool (src/widget-tools/cockpit.ts), the *_data
feeds (data-feeds.ts, over DATA_FEEDS) and the actions feed; these files are the documented
exception that calls the official SDK's server.registerTool(name, config, handler) directly
(server is the module's ModuleServer; its handler gets (args, ctx), the SDK's context with the
call's abort signal at ctx.mcpReq.signal):
- Every widget-path tool (the cockpit AND each feed) declares
inputSchema: strictToolInput({ … })(from@miragon/pulse-widget-shell/server), never a barez.object, which strips an unknown key silently;apps/mcp-server/test/strict-input.e2e.test.tsrefuses any module tool that accepts one. engine_open_cockpitspreads...showToolBinding(ENGINE_OPEN_COCKPIT, "Open Cockpit")(from@miragon/pulse-widget-shell/server) into its config, which binds the view named after the tool (_meta.ui.resourceUri: "ui://views/<tool>.html", whose resource the boot serves), adds the required passthroughoutputSchemaand stamps the Apps-SDK half of_meta; never write_metakeys by hand. Resolve the engine viaresolveEngine(args.engine, registry, ctx), which already returnsbaseUrl/cockpitUrl; never fish them out ofregistry.engines. It returnsbuildViewResult(data, facts)(from@miragon/pulse-widget-shell/server):dataIS the view's data, no title (the widget's heading names the view); the facts are the compact JSON of the route'sfacts, the model-facing text channel, while the full payload travels only instructuredContent.*_datafeeds: describe one withdataFeed({ name, title, description, shape, read })inDATA_FEEDS(src/data/feeds.ts);registerDataFeedindata-feeds.tsregisters each and spreads...appOnly(SEP-1865_meta.ui.visibility: ["app"]plusopenai/widgetAccessible; no view binding) and returnsbuildDataFeedResult(data)so the in-widgetcallTool()gets JSON back instead of the host rendering a new widget. Wrap every raw handler inwithToolErrors(from@miragon/pulse-widget-shell/server).- Shared data paths: every number a view shows comes from ONE builder in
src/data/that its*_datafeed AND the cockpit's start view (COCKPIT_ROUTES) call; never fork one. Definition lookups (id parsers, the key lookup, the per-KEY fold of the per-version statistics) come fromsrc/data/definition-info.ts. A key's latest version isfetchLatestDefinition(every tenant), never/process-definition/key/{key}, which only knows tenant-less definitions. A long key's (over ~25 characters) definition id is a bare generated UUID that names no key, so ANY key that scopes a query, a drill, a hand-off or a cockpit link is resolved, never parsed: a view queried by key uses that key; rows that carry only a definition id go throughresolveDefinitionKeys(at most ONE definition-statistics read, none when every id names its key): a primary read where the key is what the view hands on, else wrapped inoptional(...)as enrichment, so a bare id's key isnull(no drill, no link), never its UUID. A view that already reads the statistics resolves throughdefinitionKeyResolverover them.processDefinitionKeyFromIdis a display fallback only. - Honest numbers (
src/data/engine-reads.ts): PRIMARY rows and counts (what the view exists to show, and every number its facts report) propagate engine failures (read them throughcountOf/rowsOf;withToolErrorsturns the throw into a tool error), and an unknown id or key is a not-found error, never a view of zeros. Only ENRICHMENT (display names, diagram XML, cockpit links, optional history) degrades, viaoptional(...), and tonull, never to a0,[]or{}that reads as a fact. Widgets render that null as "—" (formatCountfrom@miragon/pulse-ui), the facts keep itnull(the instructions read it as unknown). Totals come from/countendpoints or statistics, never from the length of a capped page or scan: a recency scan (INCIDENT_SCAN_LIMIT, the health views'CLUSTER_SCAN_LIMIT) only enriches and says how far it reaches (scannedIncidentCount, null facts, "≥ N" viaformatCount(exact ?? { atLeast: scanned })). A view about a process definition KEY spans every deployed version (key-scoped/count, statistics summed per key); only a diagram is one version, and it is labelled. Engine timestamps compare by instant (engineDateMillis/latestEngineDate/earliestEngineDatefrom@miragon/pulse-c7-core), never as strings. A new builder adds its case to the rejection table insrc/data/honest-numbers.test.ts; the table fails without it. The table breaks EVERY read the healthy build makes (method + path + query names), so a case declares only its enrichment reads; any other read whose failure is swallowed fails. - The
show_*/*_datanaming is load-bearing:apps/mcp-server/test/widget-contract.e2e.test.tsenforces the widget_metaon every*_show_*tool and onengine_open_cockpit, and app-only visibility on every*_datafeed by name. - A widget-path tool never writes. A write is a registrar tool, so the toolset filter
(
withToolsetFilter/isToolInToolset) drops it outside its toolset, and an in-widget write calls it throughuseEngineAction, gated byengine_widget_actions_data. - Every in-widget write runs through
useEngineAction({ tool, target, resetOn, available })(src/widgets/lib/engine-action.ts), never a rawuseToolMutation/callTool. Itsallowedis the gate, the toolset AND the subject's current state (available: false for an ended, cancelled or unconfirmed instance, a suspended one where the engine refuses the write): render the control only when true (hide it and a table column it would leave empty, never just disable it). List the tool inENGINE_WIDGET_ACTIONS(src/tool-names.ts) and give it aWRITE_POLICYentry (src/widgets/lib/write-policy.ts: the query namespaces it changes, andconfirm: truefor irreversible/state-changing writes; thenrunneeds a confirmation naming its target, rendered byEngineActionDialog, with verb + object on both buttons: widget-shell'sConfirmDialog, analertdialogover the scrim, the destructive confirm in the danger role, a failure shown inside it). Everyrunpassesfeedback, the toasts' titles naming the object, fromsrc/widgets/lib/write-feedback.ts(writeFeedback.jobRetry(t, instanceName(id, businessKey))…; add an entry and itsfeedback.*keys inmessages/{en,de}.feedback.tsfor a new write): a success toast, a failure toast with the reason in words (useActionFeedback); a confirmed write's failure stays in its dialog, a read-only engine's refusal at its control, without a toast. Optimistic state readsaction.done(dropped whenresetOnchanges; pass the data the marked ROWS come from, not a sibling feed).src/widget-actions.test.tsenforces all of it structurally; add the new call site tosrc/widgets/action-gating.sites.ts+ its renderer inaction-gating.test-support.tsx. - Everything the widget tells the model goes through
useHandOff()(src/widgets/lib/hand-off.ts):<HandOffButton action="…" prompt={ask(handOff)} />and<HostModelContext content={context(view)}>. A hand-off is a typed spec: anintentkey frommessages/{en,de}.ask-ai.ts(static, no tool names) or a static viewsummary, plusids,facts,untrusted(engine text, fenced); notools: a hand-off names no tool, the module instructions and the tool descriptions steer the model. Reference:src/widgets/remediation.ts; the rules are CLAUDE.md invariant 6, enforced byscripts/hand-off-refs.test.mjs.
Step 5: verify
# while you iterate: the test files you touch (coverage off, the packages under them built first)
pnpm test:one packages/connectors/engine/engine-connector/src/tools/<domain>.wire.test.ts
# then the package and every package that depends on it (the server's goldens included)
pnpm exec turbo run typecheck test lint --filter=...@miragon/pulse-engine-connector
# the bar for every change: build, typecheck, test, lint, format
pnpm check
# for widget/shell changes additionally:
pnpm --filter @miragon/pulse-mcp-server test:host
A file run alone fails the package's coverage floors, since it loads only part of the code, so
use pnpm test:one for it; pnpm test keeps enforcing the floors.
A new or changed tool changes the wire surface: add its name to
apps/mcp-server/test/expected-tools.ts, then refresh the per-toolset goldens with
pnpm --fail-if-no-match --filter @miragon/pulse-mcp-server golden:update (it builds the
connectors first) and commit the
test/__golden__/ diff (descriptions and schemas are what the model reads; review them).
The budget counts the model-visible tools/list text plus the server instructions of each
toolset. If char-budgets.json grew, pnpm lint fails until the commit carries a
Ratchet-Exception: <why the model must read more> trailer. Trim wording first.
pnpm typecheck is the only automated check that type-checks widget .tsx code
(tsc -p tsconfig.widgets.json); never skip it. For widgets also do a manual render
check: pnpm play (playground, build and server in one command), then call the
tool in the MCP Inspector pnpm play starts next to the server (it prints the URL). The default boot
is read-only (the boot log names the toolsets). To exercise a write tool or button, run
pnpm play --admin (--deploy for deployments). Run pnpm format:check (or
pnpm format) before committing.
04 同仓库更多 Skills
setup-server
Set up, configure, run, brand, and deploy this composed MCP server. Use whenever connecting a Camunda 7 / CIB seven engine, selecting modules or toolsets (`MCP_ACTIVE_MODULES`), renaming the `@acme` scope, building the Docker image, running behind an MCP gateway, or troubleshooting boot warnings or inspector issues.
create-module
暂无简介
add-widget
Step-by-step house pattern for adding an interactive widget to a module (the React component built from the design kit `@miragon/pulse-ui` and widget-shell's view runtime, the `show_*` widget tool, the app-only `*_data` feed, the view map keyed by the show tool's name, and a widget test under `TestViewHost`). Use whenever adding or changing widgets, `show_*` tools, `*_data` feeds, in-widget data fetching, or when debugging a widget that renders empty, unstyled, or hangs on "Loading…".
pulse-cli
Inspect and operate Camunda 7, Operaton and CIB seven process engines through their REST API with the Pulse CLI (deployments, process instances, user tasks, external tasks, incidents, jobs, history, variables, decisions), and drive process instances with its workflow commands (inspect, wait, advance, retry, deploy, status). Use it whenever a task needs to read or change the state of such an engine.
shadcn
Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
mcp-apps-builder
Where to look before changing MCP server code in this repo (tools, resources, prompts, widgets, auth). Points to the house skills, the CLAUDE.md invariants, widget-shell's view runtime and the official MCP SDK's typings and docs; it teaches no framework API itself.