Writing a Custom Node
Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source:
crates/cheng-macros/src/lib.rs,crates/cheng-nodes/src/nodes/registry.rs,crates/cheng-nodes/src/schema_ext.rs,crates/cheng-core/src/domain/sandbox_capability.rs,chengflow/docs/i18n-node-convention-guide.md
Writing a node is the most common way to extend ChengOS. A node is a Rust struct with a derive macro, a typed input, a typed output, and one function. Registration, JSON Schema generation, and the property-panel UI all follow from that automatically.
The minimum node
use anyhow::Result;
use cheng_workflow_macros::{FieldOrder, Node};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::nodes::base::TypedNode;
use crate::register_node;
#[derive(Node)]
#[node(
id = "utils/word_count",
name = "Word Count",
category = "Utils",
description = "Count words in a text",
ui_icon = "hash",
ui_width = 280,
ui_height = 160,
ui_collapsible = true
)]
pub struct WordCountNode;
impl Default for WordCountNode {
fn default() -> Self { Self }
}
#[derive(Debug, Deserialize, JsonSchema, FieldOrder)]
pub struct WordCountInput {
/// Text to count.
#[schemars(title = "Text")]
pub text: String,
}
#[derive(Debug, Serialize, JsonSchema, FieldOrder)]
pub struct WordCountOutput {
#[schemars(title = "Word Count")]
pub word_count: usize,
}
impl TypedNode for WordCountNode {
type Input = WordCountInput;
type Output = WordCountOutput;
fn run(&self, input: Self::Input) -> Result<Self::Output> {
Ok(WordCountOutput { word_count: input.text.split_whitespace().count() })
}
}
register_node!(WordCountNode; cheng_core::domain::sandbox_capability::SandboxCapability::FilesystemFree);
Then export it in builtin/<category>/mod.rs, re-export in builtin/mod.rs, and rebuild. The
inventory crate collects the registration at link time — there is no registration list to
edit.
Where files go
Node implementations live under cheng-nodes/src/nodes/builtin/<directory>/, where the
directory is one of:
agent ai chat document io rag table tools translation ui utils workspace
The category value in the macro is a logical grouping for the UI and may differ from the
directory. category = "Utils" does not imply utils/.
The node id is category/name — io/input_text, ai/llm, utils/preview. It is the stable
public identity: changing it breaks every saved workflow that uses the node.
The #[node(...)] attributes
| Attribute | Required | Meaning |
|---|---|---|
id | ✅ | Stable category/name identity |
name | Display name (defaults to the struct name) | |
category | Logical grouping (defaults to General) | |
description | One-line explanation | |
ui_icon | Icon name | |
ui_color | Node colour | |
ui_width / ui_height | Canvas size | |
ui_collapsible | Whether the node can collapse | |
hidden | Hide from the node library — for internal machinery nodes |
Choosing a trait
| Trait | Signature | Use for |
|---|---|---|
TypedNode | fn run(&self, input) -> Result<Output> | Synchronous pure compute |
AsyncTypedNode | async fn run_async(&self, input, tool_ctx, exec_ctx) -> Result<Output> | I/O-bound or context-dependent work |
AsyncNode | Raw HashMap<String, Value> | Legacy — avoid in new code |
Register with register_node! for sync and register_async_node! for async.
AsyncTypedNode gives you tool_ctx and exec_ctx, which is how a node reaches repositories,
the executor, the artifact store, and the approval gate. If you need any of those, you need the
async trait even when your own logic is not I/O-bound.
Sandbox capability — do not skip this
Registration takes an optional SandboxCapability, and the classification is fail-closed:
| Capability | Meaning |
|---|---|
FilesystemFree | Touches no host paths — pure compute, DB or object-id only, network only |
SandboxAwareReadOnly | Consumes the engine-injected trusted root, reads only |
SandboxAwareReadWrite | Consumes the trusted root and writability flag; mutations still pass risk/approval gates |
ProcessIsolated | Runs host processes through the isolated launcher (Bubblewrap) |
WorkspaceProcess | Runs host processes inside the workspace authority with cwd containment, command policy, and a mandatory approval gate — organisational confinement, not kernel isolation |
UnsupportedForCli | Touches host paths without sandbox awareness; rejected for CLI execution |
An unclassified new node is UnsupportedForCli. That is deliberate: adding a
filesystem-capable node without classifying it fails closed rather than widening the boundary.
If your node cannot run in the CLI and you did not expect that, this is why.
The guard runs at the execution chokepoint, not only at validation time, because nested executions — subflows, batch subflows, workflow-backed skills — inherit the parent's sandbox but are never statically validated.
Driving the UI from the schema
The property panel is generated from your input struct's JSON Schema. Control the result with
#[schemars(...)] and the helpers in schema_ext:
| Helper | Renders as |
|---|---|
select_schema::<T> | Dropdown from an enum |
multi_select_schema | Multi-select |
dynamic_select_schema | Dropdown populated from an API endpoint |
switch_schema | Toggle |
slider_schema / slider_schema_with_step | Slider |
textarea_schema / textarea_port_schema | Multi-line text |
code_editor_schema | Code editor |
rich_text_schema | Rich text |
number_schema | Number input |
datetime_schema | Date-time picker |
color_picker_schema | Colour picker |
credential_select_schema | Credential picker |
file_upload_schema / image_upload_schema | Upload control |
workflow_select_schema | Workflow picker |
artifact_port_schema and friends | Artifact ports |
deprecated_schema / deprecated_bool_schema | Marked deprecated |
with_placeholder | Adds a placeholder |
runtime_conditional | A port present only under a runtime condition |
Useful extension keys you can set directly: x-hidden (hide the field — used for
engine-injected ambient values like workspace_id), x-port (expose as a connection port),
x-control, x-options-source, x-depends-on for cascading selects.
#[derive(FieldOrder)] on the input and output structs controls the order fields appear in.
Implement collapsed_visible_output_fields to choose what a collapsed node shows on the canvas.
Exposing a node as an agent tool
A node becomes a tool an agent can call through register_tool!, which declares the tool id,
name, description, category, and a JSON Schema for the parameters. The description is what the
model reads to decide when to call it — write it for the model, not for a human skimming a list.
Use #[llm_input] on the fields the model may fill. Fields without it are static canvas
configuration the model never sees, which is exactly how the
approval switches on hub nodes stay out of the model's reach.
Also add a node_skills/<node>/ entry: those are the LLM-facing usage docs, and without one the
agent has the schema but no guidance.
Internationalisation
Backend supplies keys and English source text; the frontend looks up translations and falls
back. English is the source of truth; Chinese lives in config/i18n/nodes/*.json.
Node-level keys derive from the i18n key prefix:
{i18nKeyPrefix}.name
{i18nKeyPrefix}.description
Then:
cargo run -p chengctl -- missing-i18n --fail-if-missing
cargo run -p chengctl -- translate-missing --engine llm
Declare placeholder / x-placeholder only on fields that genuinely need input guidance —
there is no node-level wildcard. See docs/i18n-node-convention-guide.md.
Checklist
- Create the struct in
builtin/<directory>/, deriveNode, setidand metadata. - Define
InputandOutputwithDeserialize/Serialize,JsonSchema,FieldOrder. - Implement
TypedNodeorAsyncTypedNode. - Call
register_node!/register_async_node!with a sandbox capability. - Export in the category
mod.rsand re-export inbuiltin/mod.rs. - Add
register_tool!andnode_skills/<node>/if agents should call it. - Run
cargo build, thenchengctl missing-i18n. - Check the property panel renders as intended — no frontend change should be needed.
Next steps
- Node Configuration & Schema-Driven Forms — how the panel decides.
- Crate Structure — where everything lives.
- Tool Discovery & Tool Hub — how agents find your tool.