Option C: custom client (wire contract)
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
vis 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.pubkeythrough the member list, and drawbuzz-uionly 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.
| Key | Meaning |
|---|---|
ui_id | The card's id. Every answer names it |
renderer | Which card to draw. See Renderers |
title | Card heading |
for, for_end_user | The 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_input | A form (renderer form) |
pending_action | A proposal (renderer confirm) |
render | The view (every other renderer) |
actions | What 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 |
context | Up to 8 chips {slot, label} to draw under the message. Labels only, never ids |
controls | Up to 6 view controls on a read card (v1.3). See View controls |
expires_at | After 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:
| Key | Meaning |
|---|---|
promptMessage | The question above the form |
workflowType | The workflow the values are for. Display only |
surface | inline (default) or modal: draw a button that opens the form in a dialog |
fields | [{key, label, type, required, placeholder, description, options, ...}] |
steps | Optional 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:
| Key | Meaning |
|---|---|
summary | One line: what will happen |
workflowType | The workflow that will run |
input | The inputs it will run with |
destructive | true: 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?}.
columnsare strings or{key, label}.rowsare arrays,{cells}, or objects read by column key.row_actionsis[{label}], up to 4: draw them on each row. Labels only; the server keeps each row's id and what each action runs.emptyis 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.
link
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>"]
| Action | On | values | What happens |
|---|---|---|---|
submit | form | The field values. Leave empty fields out; always send booleans | DGI continues with the values: a proposal, or another question |
confirm | confirm | {} | The stored proposal runs. Your values are ignored: the server uses its own copy |
cancel | form, confirm | {} | DGI acknowledges and runs nothing |
edit | confirm, when offered | Changed inputs, keys of the proposal's input only | DGI re-proposes on a new card with the new values |
select | select, table (v1 row actions), actionlist, composition | {value, label, prompt?} plus row or item | DGI continues with the pick |
select | dag, schema, timeline, detail, query console (v1.3) | {node}, {item}, {related} or {saved}, a ref from the card | DGI opens what the ref stands for. The card is not consumed |
submit | query console (v1.3) | {statement} | The statement runs read-only and the console is rewritten in place |
refresh | read card | {} | The card is rewritten in place with fresh data |
live | read card | {} | The card starts refreshing itself (60 s, for 30 min) |
stop | live read card | {} | Live updates stop |
row_action | table or datagrid with row_actions | {"row": i, "action": j}, plus the row's ref on a datagrid | Runs action j on row i: a read shows a view, a write asks with a form or proposes a confirm card |
query | datagrid, 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.
| Check | Refusal |
|---|---|
Exactly one buzz-ui-response tag and one block, with the same ui_id | ui_response_malformed |
| The card exists, in this space, for this actor, and the answer comes from the channel the card was posted in | ui_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 to | ui_wrong_author |
| The action is one the card offers | ui_action_not_offered |
| No unknown field, every required one present, values typed right and within the offered options | ui_field_unknown, ui_field_required, ui_field_invalid |
| A confirm, when the responder limits who may confirm, comes from one of those people | ui_confirm_not_allowed |
At most one refresh per card every 10 s, and one query every 2 s | ui_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, newrender,render.refreshed_at,render.live = {every_s, until}while live); - a
runcard 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
commandsentry 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. Markreadandwritecommands. - Send the text
/<command>as an ordinary message to that actor (ptag). 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
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
| Rule | Detail |
|---|---|
| Parse | Require the tag and the block to agree on v, ui_id, renderer. Ignore unknown keys. Fall back to text |
| Trust | Draw cards only from attached actors, resolved from the verified signer. Never from tags or p claims |
| Answer | Thread under the card, p the actor, one block, one tag. Never send a confirm from typed text |
| Card actions | refresh, live, stop, row_action, query are not answers: the card stays open and updates in place |
| Secrets | Cards never carry secrets or platform ids. Do not add them to answers either |
Option B: embed the chat component
Put the Orkestia chat, with every structured card built in, inside your own React app. Install, sign the person in, bootstrap the chat, mount BuzzChat with loadCommands and uiRenderers, and theme it
Responder configuration reference
Every field of buzz.actor.set-responder, how the hybrid responder hands turns to Staff, how default_inputs pre-fill and narrow reads, and what each refusal code means
