All docs / npm packages

@decentnetwork/chat-components

Interactive forms inside chat messages: a shared schema between an agent that asks and a client that renders. No HTML, URLs or scripts.

Version
0.1.3 · npm
Platform
Node.js 20+ · browser
Source
Source repository not public yet
npm install @decentnetwork/chat-components

@decentnetwork/chat-components defines interactive forms that travel inside ordinary chat messages. An agent sends a message made of a human-readable sentence plus a fenced JSON block that declares a form (select boxes, text inputs, a submit button). A client that supports forms draws them with its own trusted widgets; a client that does not shows the sentence. The answer goes back the same way, as a chat message with a submission block. No HTML, URLs or scripts cross the wire, and every parser treats its input as untrusted. The package has no dependencies and works in Node and in the browser.

When to use it

  • Use it when a bot or agent on Decent Network needs a structured answer from a person (pick an option, fill in a field) and you control the client that renders the chat.
  • Use it on both sides: the agent builds forms and reads submissions, and the client reads forms and builds submissions. That way the two sides cannot drift into slightly different formats.
  • It never touches the network. Send its output with any transport that carries text, usually Peer.sendText from @decentnetwork/peer, and pass received text to readMessage.
  • It is not a UI kit. It gives you validated data; your client draws the widgets.

Install

npm install @decentnetwork/chat-components

The package is an ES module. Its package.json also maps require to the same ESM file, so require() works only on Node versions that can load ES modules synchronously. It worked on Node.js 24.3.0; Node 20 was not tested.

Quickstart

This file plays both sides: the agent builds a form, the client reads it and answers, and the agent routes the answer.

// quickstart.mjs — build a form, read it on the client, answer it, route the answer.
// Run: node quickstart.mjs
import { encodeForm, readMessage, buildInteraction, encodeInteraction, answers } from "@decentnetwork/chat-components";

// Agent side: a form is data inside an ordinary chat message.
const ask = encodeForm("Voice clone: pick a voice and a quality.", {
  custom_id: "vc-form-7f3a",
  components: [
    { type: "select", id: "voice", label: "Voice", required: true,
      options: [{ label: "Alto", value: "alto" }, { label: "Tenor", value: "tenor" }] },
    { type: "select", id: "quality", label: "Quality", default: "good",
      options: [{ label: "Fast", value: "fast" }, { label: "Good", value: "good" }, { label: "Best", value: "best" }] },
    { type: "submit", id: "go", label: "Start" },
  ],
});
console.log("--- message on the wire ---\n" + ask);

// Client side: one call tells you what to show.
const shown = readMessage(ask);
console.log("--- client renders ---");
console.log("text:", shown.text);
console.log("widgets:", shown.form.components.map((c) => `${c.type}:${c.id}`).join(", "));

// The user submits; the client sends the answer back as another chat message.
const answer = buildInteraction("vc-form-7f3a", "go", { voice: "tenor", quality: "best" });
const reply = encodeInteraction(answer, "Voice: Tenor, Quality: Best");
console.log("--- reply on the wire ---\n" + reply);

// Agent side again: route on custom_id.
const { interaction } = readMessage(reply);
if (interaction?.custom_id === "vc-form-7f3a") {
  console.log("agent got:", interaction.values);
}

// Answered state is derived from the transcript, not stored separately.
console.log("answered:", [...answers([reply]).keys()]);

// Untrusted input never throws: garbage just shows as text with no form.
console.log("garbage:", readMessage("hi\n```decent-form\n{not json\n```"));

Verified output (npm @decentnetwork/chat-components@0.1.3, Node.js 24.3.0, 2026-11-06):

--- message on the wire ---
Voice clone: pick a voice and a quality.

```decent-form
{"v":1,"custom_id":"vc-form-7f3a","components":[{"type":"select","id":"voice","label":"Voice","required":true,"options":[{"label":"Alto","value":"alto"},{"label":"Tenor","value":"tenor"}]},{"type":"select","id":"quality","label":"Quality","default":"good","options":[{"label":"Fast","value":"fast"},{"label":"Good","value":"good"},{"label":"Best","value":"best"}]},{"type":"submit","id":"go","label":"Start"}]}
```
--- client renders ---
text: Voice clone: pick a voice and a quality.
widgets: select:voice, select:quality, submit:go
--- reply on the wire ---
Voice: Tenor, Quality: Best

```decent-interaction
{"v":1,"custom_id":"vc-form-7f3a","component":"go","values":{"voice":"tenor","quality":"best"}}
```
agent got: { voice: 'tenor', quality: 'best' }
answered: [ 'vc-form-7f3a' ]
garbage: { text: 'hi', form: null, interaction: null }

To send it for real, pass ask to peer.sendText(userid, ask). On the other side, pass msg.text from peer.onText to readMessage.

API reference

All exports come from the package root.

Functions

FunctionDescription
encodeForm(text: string, form: Form): stringAgent side. Returns text, a blank line, and a ```decent-form fenced JSON block (with "v": 1 added). Throws if text is empty or only whitespace: the text is the fallback for clients without form support.
readMessage(text: string): ReadMessageEither side. Returns { text, form, interaction }: the human-readable text with the payload block removed, a validated Form or null, and a validated Interaction or null. Never throws. A block that fails validation is still removed from text. Only the first block of each kind is read.
buildInteraction(customId: string, component: string, values: Record<string, string | FileValue>): InteractionClient side. Builds a submission, capping lengths so a local bug cannot send an oversized payload.
encodeInteraction(interaction: Interaction, summary?: string): stringClient side. Returns the summary line plus a ```decent-interaction block. When summary is left out, the default joins key: value pairs with a full-width comma (,) and uses (已提交) when there are no values. Pass your own summary to control the language.
answers(texts: readonly string[]): Map<string, Interaction>The submissions found in a list of message texts, keyed by custom_id. When a form was answered twice, the first answer wins. Pass outbound texts on a client (to collapse answered forms) and inbound texts on an agent.
parseForm(raw: unknown): Form | nullValidate an already-parsed form object. The object must carry v: 1.
parseInteraction(raw: unknown): Interaction | nullValidate an already-parsed submission object. Also requires v: 1.

Types

type ComponentType = "select" | "submit" | "text" | "button" | "attachment";

interface ChatComponent {
  type: ComponentType;
  id: string;
  label?: string;
  required?: boolean;
  default?: string;
  placeholder?: string;
  options?: SelectOption[];   // select
  accept?: string[];          // attachment: MIME patterns for the file picker
  max_bytes?: number;         // attachment: size hint for the file picker
}
interface SelectOption { label: string; value: string }
interface Form { custom_id: string; components: ChatComponent[] }
interface Interaction { custom_id: string; component: string; values: Record<string, string | FileValue> }
interface FileValue { fileId: string; name: string; size: number }   // fileId = sha256 of the content
interface ReadMessage { text: string; form: Form | null; interaction: Interaction | null }

Validation rules (what parseForm / readMessage drop)

  • select, text and submit are rendered today. button and attachment are declared in the types but dropped by the parser in 0.1.3.
  • Unknown component types are dropped, not rejected.
  • A default that is not one of the select's option values is removed.
  • Duplicate component ids are dropped.
  • A form with no submit, or a submit with no inputs, is unusable: form is null.
  • A payload without "v": 1 is ignored.
  • Every count and length is capped (see the constants below). Payload blocks over MAX_PAYLOAD_CHARS are not parsed.

Constants

ConstantValue
WIRE_VERSION1
FORM_FENCE / INTERACTION_FENCE"decent-form" / "decent-interaction"
MAX_COMPONENTS12
MAX_OPTIONS40
MAX_LABEL_LEN120
MAX_ID_LEN64
MAX_VALUE_LEN512
MAX_CUSTOM_ID_LEN128
MAX_ACCEPT12
MAX_PAYLOAD_CHARS65536
INPUT_TYPESReadonlySet of "select", "text", "attachment": the component types that collect a value

Configuration

There is nothing to configure: no options object, environment variables, config files or CLI. Behavior is fixed by the constants above.

Two practices from the package's own guidance:

  • Pick a unique custom_id per form and store whatever the form refers to under that id when you send it. Do not work out later which form a submission answers from "the newest thing this peer sent".
  • Treat accept and max_bytes as hints for the file picker. The receiving agent still has to validate the file.

Platforms

  • Node.js 20+ (engines.node >= 20.0.0). Verified on Node.js 24.3.0.
  • Browsers: plain ES module with no dependencies and no Node built-ins. It was not run in a browser for this page.
  • Transport-agnostic: the output is plain text.

Version, source and changelog