Custom components

Declare a KletsoComponentSpec, register a builder, sync the manifest, and let the model render your widget.

The agent can only render component types that are (a) known to the model through the dashboard and (b) rendered by a builder in your app. Both come from one Dart declaration.

1. Declare the spec

In a file that imports only package:kletso_core (no Flutter), by convention lib/kletso_components.dart:

import 'package:kletso_core/kletso_core.dart';

const orderStatusSpec = KletsoComponentSpec(
  type: 'acme.orderStatus',                 // lowercase, dot-namespaced
  description: 'Current status of one order with ETA and courier.',
  props: {
    'type': 'object',
    'required': ['orderId', 'status'],
    'properties': {
      'orderId': {'type': 'string'},
      'status': {'type': 'string', 'enum': ['processing', 'shipped', 'out_for_delivery', 'delivered']},
      'eta': {'type': 'string'},
      'courier': {'type': 'string'},
    },
    'additionalProperties': false,
  },
  example: {'orderId': 'ORD-2201', 'status': 'out_for_delivery', 'eta': '15 min', 'courier': 'Yamato'},
  actions: ['track'],
  version: 1,
);

const kletsoComponents = [productCardSpec, orderStatusSpec];

props is a JSON Schema (Draft 2020-12 subset: object, array, string, number, boolean, enum; no $ref). The description is what the model reads when deciding to use the type. actions lists the action ids your widget will fire.

2. Register the builder

Kletso.instance.registerComponent(orderStatusSpec.type, (ctx, node) => OrderStatusTile(
  orderId: node.string('orderId'),
  status: node.string('status', fallback: 'processing'),
  eta: node.stringOrNull('eta'),
  onTrack: () => ctx.executeAction('track'),
), spec: orderStatusSpec);
  • node getters never throw and bindings are already resolved: string, stringOrNull, number, numberOrNull, boolean, list, mapList, map, childIds, action(id), has.
  • ctx (KletsoBuildContext) gives you context, theme, surface, data, depth, child(id) and children() for container types, executeAction(actionId, args:), execute(action), openUrl(url).
  • Host registrations win over built-ins, so you can replace map, video or audio with real players (the example does).

Precedence at render time: your builder → SDK built-in → KletsoFallback (plain text from the surface’s fallbackText).

3. Sync to the dashboard

dart run kletso_flutter:sync                 # reads lib/kletso_components.dart
dart run kletso_flutter:sync --file lib/specs.dart --out build/kletso.components.json

The command compiles a tiny runner against your spec file (it refuses files that import Flutter or kletso_flutter), and writes a kletso.components/v1 manifest. In the dashboard go to Components → Sync from code, paste or drop the file, and import. New types are added, changed types get a new version, unchanged ones are reported as such. Synced types are marked from code; editing them in the dashboard is overwritten by the next sync.

Then tick the type in the agent’s UI tab and publish. Consider adding a line to the system prompt such as “render orders as acme.orderStatus”.

4. What the model sends

{
  "type": "acme.orderStatus",
  "props": { "orderId": "ORD-2201", "status": "out_for_delivery", "eta": "15 min", "courier": "Yamato" },
  "actions": [ { "id": "track", "kind": "local", "name": "open_tracking", "args": { "orderId": "ORD-2201" } } ]
}

The runtime validates props against your schema and the whole surface against the limits before it reaches the app. See Surfaces.

5. Loaders (what shows while the agent is still writing the cards)

The runtime streams ui.pending while the model is still writing a render_ui call: the surface id and the component ids/types seen so far. The chat shows one placeholder per announced component (layout types excluded) and swaps in the real surface on ui.render; text the model wrote before the call stays above. Register a loader per type so the placeholder has the right shape; types without one get the SDK’s generic shimmer.

Kletso.instance.registerLoader('ds.eventCard', (ctx) => const EventCardSkeleton());

ctx carries the component type, its id and position in the coming surface, and the theme. The same loaders back the built-in loading block when a surface (from the agent or a rule) asks for placeholders: {"type":"loading","props":{"for":"ds.eventCard","count":3}}, later replaced through ui.patch or the next ui.render.

Tips

  • Keep one component per widget and give it a clear description; models pick types by description.
  • Put display formatting in the widget, not the schema. Send price: 1899, currency: 'INR', format ₹1,899 in Flutter.
  • Use example generously; it is shown in the dashboard preview and helps the model.
  • Bump version when you change props in a breaking way. The dashboard keeps versions.

Last updated 2026-09-28 · Report an issue with this page