Orkestia
Blog
Chat

Option C: custom client (wire contract)

Draw structured chat cards in your own chat client. The buzz-ui block and tag, every renderer and its fields, answers and card actions, server-side validation, in-place edits, quick replies, the status line and slash commands

Every structured card is an ordinary chat message (Nostr kind 9) that carries a JSON block in its text and one tag. A client that knows the contract draws the card; a client that does not shows the text, which always stands on its own. This page is the contract, version 1 (with its additive revisions 1.1, 1.2 and 1.3). The same card object is what dgi.chat.turn returns outside the chat. Structured chat is part of DGI and is Alpha.

You still configure the actor as in Option A, and your client connects the person to the relay as in Option B: bootstrap, the open entry point, then the relay with the person's own key.

Rules that do not change

  • v is 1. Revisions add optional keys only. Ignore keys you do not know.
  • Draw a card only from an attached actor. Resolve the author from the verified event.pubkey through the member list, and draw buzz-ui only when that member is an attached actor. The same block from a person is text.
  • The server never trusts the block. It checks every answer against the card it stored when it posted it. Your client decides how things look, never what is accepted.
  • Unknown renderer: show the text. Everything a person needs is also in the message text above the block.

Actor to chat: the buzz-ui block

The message text is a human-readable line, then a fenced block:

Here are the open tickets.

```buzz-ui
{"v":1,"ui_id":"<32 hex>","renderer":"table","title":"Open tickets",
 "for":"<author public key, 64 hex>","for_end_user":"<end user uuid>",
 "render":{...},"actions":["refresh","row_action","live"],
 "context":[{"slot":"project","label":"Website"}],
 "expires_at":"2026-09-24T18:30:00Z"}
```

and the tag:

["buzz-ui", "1", "<renderer>", "<ui_id>"]

The tag and the block must agree on v, ui_id and renderer. Otherwise ignore the block.

KeyMeaning
ui_idThe card's id. Every answer names it
rendererWhich card to draw. See Renderers
titleCard heading
for, for_end_userThe person the card answers. Draw it read-only for anyone else, with a note such as "This card is for Ana". The server refuses anyone else anyway
pending_inputA form (renderer form)
pending_actionA proposal (renderer confirm)
renderThe view (every other renderer)
actionsWhat the card offers: confirm, cancel, edit on a confirm card; refresh, row_action, live, stop, query on a read card; select or submit on the views that open something or run a statement
contextUp to 8 chips {slot, label} to draw under the message. Labels only, never ids
controlsUp to 6 view controls on a read card (v1.3). See View controls
expires_atAfter this, draw the card disabled. A card that offers card actions (refresh, live) never expires in the client

The whole message stays under 63 KiB. A block too large loses render first, then pending_action.plan; a form or proposal that still does not fit is posted as text.

Renderers

form

pending_input:

KeyMeaning
promptMessageThe question above the form
workflowTypeThe workflow the values are for. Display only
surfaceinline (default) or modal: draw a button that opens the form in a dialog
fields[{key, label, type, required, placeholder, description, options, ...}]
stepsOptional wizard pages [{title, fields:[key]}]. Submit once, at the end

Field type: text, textarea, number, email, url, date, time, select, multiselect, boolean, file. A file field may carry accept and max_bytes; upload the file through the chat's own media upload on the space relay and submit the resulting https URL. A URL on any other host is refused.

confirm

pending_action:

KeyMeaning
summaryOne line: what will happen
workflowTypeThe workflow that will run
inputThe inputs it will run with
destructivetrue: style the Confirm button as a warning
plan{summary, inputs:[{key, value, field_label}]}: draw field_label = value rows, up to 8. plan.summary is a good card title

Offer the buttons listed in the block's actions: Confirm, Cancel, and Edit when edit is listed.

table

render: {title, columns, rows, row_actions?, empty?, refreshed_at?, live?}.

  • columns are strings or {key, label}. rows are arrays, {cells}, or objects read by column key.
  • row_actions is [{label}], up to 4: draw them on each row. Labels only; the server keeps each row's id and what each action runs.
  • empty is the text for no rows.

chart

render: {chart: "bar"|"line"|"pie", title, x:[label], series:[{name, values:[number]}], unit?, source?, as_of?}. At most 6 series and 60 points. null is a gap.

kpi

render: {title?, tiles:[{label, value, unit?, delta?, trend?: "up"|"down"|"flat", source?, as_of?}]}. At most 8 tiles.

logs

render: {title, content, language?, truncated?}. content is at most 12,000 characters. Draw it monospace, scrollable, with a copy button.

render: {title, description, url, label}. One button that opens url in a new tab. The server posts only https URLs on the organization console (https://app.orkestia.dev, or your space's console origin). Anything else arrives as text. Check it again in your client.

run

render: {workflow_id, workflow_type, title, status: "running"|"success"|"failed"|"cancelled", state, steps:[{name, status, started_at?, finished_at?}], started_at, updated_at, failure_reason?}. A live card for a run DGI started in this conversation. The server rewrites it in place until the run ends. failure_reason is a short code, never an error text.

composition

render: {title, composition_ref, steps:[{ref, workflow_type, label, inputs, actions?}], actions:[{label, prompt}]}. Only for replies that run as an organization member with compose on. Step and plan actions answer as select with the action's prompt.

select

render: {title, options:[{value, label, prompt?}], field?}. Answer with select.

actionlist

render: {title, items:[{title, subtitle?, actions:[{value, label, prompt?}]}]}. Answer with select and the item index.

The v1.3 views

dag, schema, datagrid, query, diff, detail and timeline were added in revision 1.3, together with pending_action.diff on confirm cards and view controls. Their fields and interactions are in the card catalog. They are views: they never expire, and nothing sent from them consumes the card.

Read-only views (logs, run, chart, kpi, link and the v1.3 views) accept no answer, except the card actions below and the picks the v1.3 views offer.

Person to actor: answers

An answer is a kind-9 message threaded under the card (NIP-10 reply to the card event), with a p tag for the actor, an echo line for any client, and a fenced block:

Sent: Title = Login page broken, Priority = High

```buzz-ui-response
{"v":1,"ui_id":"<the card's ui_id>","action":"submit","values":{"title":"Login page broken","priority":"priority"}}
```

and the tag:

["buzz-ui-response", "1", "<ui_id>"]
ActionOnvaluesWhat happens
submitformThe field values. Leave empty fields out; always send booleansDGI continues with the values: a proposal, or another question
confirmconfirm{}The stored proposal runs. Your values are ignored: the server uses its own copy
cancelform, confirm{}DGI acknowledges and runs nothing
editconfirm, when offeredChanged inputs, keys of the proposal's input onlyDGI re-proposes on a new card with the new values
selectselect, table (v1 row actions), actionlist, composition{value, label, prompt?} plus row or itemDGI continues with the pick
selectdag, schema, timeline, detail, query console (v1.3){node}, {item}, {related} or {saved}, a ref from the cardDGI opens what the ref stands for. The card is not consumed
submitquery console (v1.3){statement}The statement runs read-only and the console is rewritten in place
refreshread card{}The card is rewritten in place with fresh data
liveread card{}The card starts refreshing itself (60 s, for 30 min)
stoplive read card{}Live updates stop
row_actiontable or datagrid with row_actions{"row": i, "action": j}, plus the row's ref on a datagridRuns action j on row i: a read shows a view, a write asks with a form or proposes a confirm card
querydatagrid, query console, card with controls (v1.3){sort?, filters?, cursor?} or {params}The stored read runs again with only those values, and the card is rewritten in place

refresh, live, stop, query and the console's submit come back as an edit of the card, not as a new reply, so do not show a "thinking" indicator for them.

What the server checks

Before anything runs, every answer must pass these checks against the card the server stored. A refusal posts a short line in the chat and consumes nothing, so the right person can still answer.

CheckRefusal
Exactly one buzz-ui-response tag and one block, with the same ui_idui_response_malformed
The card exists, in this space, for this actor, and the answer comes from the channel the card was posted inui_unknown
Not answered before (forms and confirms are single-use)ui_consumed
Not expired (forms and confirms expire after 30 minutes)ui_expired
From the person the card was issued toui_wrong_author
The action is one the card offersui_action_not_offered
No unknown field, every required one present, values typed right and within the offered optionsui_field_unknown, ui_field_required, ui_field_invalid
A confirm, when the responder limits who may confirm, comes from one of those peopleui_confirm_not_allowed
At most one refresh per card every 10 s, and one query every 2 sui_rate_limited

Prose never confirms. A typed "yes", or a buzz-ui-response block typed without the tag, is an ordinary message.

In-place edits (kind 40003)

The server changes a posted message by publishing a kind-40003 edit of it: ["h", <channel>], ["e", <message id>], content = the new text, block included. It uses edits for:

  • a refreshed or live read card (same ui_id, new render, render.refreshed_at, render.live = {every_s, until} while live);
  • a run card moving through its states;
  • the status line below.

Apply an edit only when its author is the message's author. Keep the card keyed by ui_id, so the same card updates rather than a new one appearing. Do not mark these edits as "edited".

Quick replies

An answer may carry up to five tags:

["buzz-quick-reply", "Blocked tickets"]

Draw each as a button under that message, in tag order, as text (never HTML). Tapping one posts the label verbatim as the person's own message, in the same thread, addressed to the actor. Show them only on the newest message of the thread, and hide them once the person writes. If the relay refuses a tagged answer, the platform re-posts it without client tags, so never treat missing buttons as a signal.

The status line

While an actor works, it may post one message with the tag ["buzz-status", "1"], edit it a few times (kind 40003) with a short progress text, and delete it (kind 5) when the answer arrives. It is not a chat message: draw it as a subdued line where the thinking indicator sits, never count it as unread, never notify on it, never let it open a thread.

Slash commands

  • Load an actor's commands with the commands entry point from the chat bootstrap, as the signed-in person, with the actor's relay public key:
start the workflow named in bootstrap.entrypoints.commands with {"actor_public_key": "<64 hex>", "locale": "en"}
→ {"commands": [{"command": "ticket-search", "title": "Ticket: search", "description": "...", "kind": "read"}, ...]}
  • Show a palette when the draft starts with /, filtered as the person types. Mark read and write commands.
  • Send the text /<command> as an ordinary message to that actor (p tag). DGI maps it to the workflow: a read runs, a write asks for its inputs.

The list is empty for a person who cannot talk to that actor. It never contains workflow types or platform ids.

Ask your AI assistant

prompts
Using https://docs.orkestia.dev/raw/chat/option-c-custom-client.md, write a TypeScript parser that extracts the buzz-ui block and tag from a kind 9 event and returns null unless they agree.

Write a function that builds a buzz-ui-response kind 9 event for a form submit, threaded under the card and addressed to the actor.

List every refusal code a structured chat answer can get and what my client should show for each.

For AI agents

RuleDetail
ParseRequire the tag and the block to agree on v, ui_id, renderer. Ignore unknown keys. Fall back to text
TrustDraw cards only from attached actors, resolved from the verified signer. Never from tags or p claims
AnswerThread under the card, p the actor, one block, one tag. Never send a confirm from typed text
Card actionsrefresh, live, stop, row_action, query are not answers: the card stays open and updates in place
SecretsCards never carry secrets or platform ids. Do not add them to answers either