@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.
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.sendTextfrom@decentnetwork/peer, and pass received text toreadMessage. - It is not a UI kit. It gives you validated data; your client draws the widgets.
Install
npm install @decentnetwork/chat-componentsThe 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
| Function | Description |
|---|---|
encodeForm(text: string, form: Form): string | Agent 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): ReadMessage | Either 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>): Interaction | Client side. Builds a submission, capping lengths so a local bug cannot send an oversized payload. |
encodeInteraction(interaction: Interaction, summary?: string): string | Client 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 | null | Validate an already-parsed form object. The object must carry v: 1. |
parseInteraction(raw: unknown): Interaction | null | Validate 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,textandsubmitare rendered today.buttonandattachmentare declared in the types but dropped by the parser in 0.1.3.- Unknown component types are dropped, not rejected.
- A
defaultthat 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:
formisnull. - A payload without
"v": 1is ignored. - Every count and length is capped (see the constants below). Payload blocks over
MAX_PAYLOAD_CHARSare not parsed.
Constants
| Constant | Value |
|---|---|
WIRE_VERSION | 1 |
FORM_FENCE / INTERACTION_FENCE | "decent-form" / "decent-interaction" |
MAX_COMPONENTS | 12 |
MAX_OPTIONS | 40 |
MAX_LABEL_LEN | 120 |
MAX_ID_LEN | 64 |
MAX_VALUE_LEN | 512 |
MAX_CUSTOM_ID_LEN | 128 |
MAX_ACCEPT | 12 |
MAX_PAYLOAD_CHARS | 65536 |
INPUT_TYPES | ReadonlySet 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_idper 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
acceptandmax_bytesas 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
- Verified version: 0.1.3 (npm
latest, published 2026-07-27). - npm: https://www.npmjs.com/package/@decentnetwork/chat-components
- Source: the source repository is not public yet. The published code is in the npm package: https://www.npmjs.com/package/@decentnetwork/chat-components?activeTab=code
- Changelog: none is published for this package.
- License: GPL-3.0-or-later.