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

AttributeRequiredMeaning
id✅Stable category/name identity
nameDisplay name (defaults to the struct name)
categoryLogical grouping (defaults to General)
descriptionOne-line explanation
ui_iconIcon name
ui_colorNode colour
ui_width / ui_heightCanvas size
ui_collapsibleWhether the node can collapse
hiddenHide from the node library — for internal machinery nodes

Choosing a trait

TraitSignatureUse for
TypedNodefn run(&self, input) -> Result<Output>Synchronous pure compute
AsyncTypedNodeasync fn run_async(&self, input, tool_ctx, exec_ctx) -> Result<Output>I/O-bound or context-dependent work
AsyncNodeRaw 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:

CapabilityMeaning
FilesystemFreeTouches no host paths — pure compute, DB or object-id only, network only
SandboxAwareReadOnlyConsumes the engine-injected trusted root, reads only
SandboxAwareReadWriteConsumes the trusted root and writability flag; mutations still pass risk/approval gates
ProcessIsolatedRuns host processes through the isolated launcher (Bubblewrap)
WorkspaceProcessRuns host processes inside the workspace authority with cwd containment, command policy, and a mandatory approval gate — organisational confinement, not kernel isolation
UnsupportedForCliTouches 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:

HelperRenders as
select_schema::<T>Dropdown from an enum
multi_select_schemaMulti-select
dynamic_select_schemaDropdown populated from an API endpoint
switch_schemaToggle
slider_schema / slider_schema_with_stepSlider
textarea_schema / textarea_port_schemaMulti-line text
code_editor_schemaCode editor
rich_text_schemaRich text
number_schemaNumber input
datetime_schemaDate-time picker
color_picker_schemaColour picker
credential_select_schemaCredential picker
file_upload_schema / image_upload_schemaUpload control
workflow_select_schemaWorkflow picker
artifact_port_schema and friendsArtifact ports
deprecated_schema / deprecated_bool_schemaMarked deprecated
with_placeholderAdds a placeholder
runtime_conditionalA 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

  1. Create the struct in builtin/<directory>/, derive Node, set id and metadata.
  2. Define Input and Output with Deserialize/Serialize, JsonSchema, FieldOrder.
  3. Implement TypedNode or AsyncTypedNode.
  4. Call register_node! / register_async_node! with a sandbox capability.
  5. Export in the category mod.rs and re-export in builtin/mod.rs.
  6. Add register_tool! and node_skills/<node>/ if agents should call it.
  7. Run cargo build, then chengctl missing-i18n.
  8. Check the property panel renders as intended — no frontend change should be needed.

Next steps