# 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

```rust
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:

```text
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](/docs/exec-approvals-reviews) 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:

```text
{i18nKeyPrefix}.name
{i18nKeyPrefix}.description
```

Then:

```bash
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

- [Node Configuration & Schema-Driven Forms](/docs/editor-node-configuration) — how the panel decides.
- [Crate Structure](/docs/dev-crate-structure) — where everything lives.
- [Tool Discovery & Tool Hub](/docs/agent-tools-and-hub) — how agents find your tool.
