Option D: any app or API (dgi.chat)
The dgi.chat.* workflows give any client the same structured chat the Buzz chat draws, without a chat space, a relay or a chat key. Your app sends a message and gets back text, at most one card and a few chips. The person answers the card, and your app sends that answer back. Structured chat is part of DGI and is Alpha.
TL;DR
- Four workflows. An admin saves a profile once with
dgi.chat.profile.save. Your app callsdgi.chat.turnfor every message anddgi.chat.respondfor every answer to a card.dgi.chat.profile.getreads a profile back. - Same cards as the chat. The
uiobject is the chat wire contract, v1.3 (block versionvis still1). Buzz posts the very same object inside itsbuzz-uifence. Every card is listed in the card catalog. - Jev first, the LLM off by default. A new profile decides with
decision_engine: "jev"andllm_fallback: false. A turn Jev cannot decide comes back as chips, never as a generated answer. - The caller is the principal. A member runs as themselves. An end user, signed in with your app's token, runs in DGI's end-user lockdown. Nobody chooses it in the input.
- Writes need a stored confirm. A confirm runs the server's own copy of the proposal, checked by
action_input_hash, once, within 30 minutes. - Ordinary workflows. Start them with
POST /api/workflows/startand read the run until it ends, or use the MCP toolsstart_workflowandwatch_workflow. The organization comes from the token.
When to use it
| You have | Use |
|---|---|
| An Orkestia chat space and the hosted page fits | Option A |
| A React app that should show the Orkestia chat | Option B |
| A chat client of your own on the relay | Option C |
| Any other surface: a web or mobile app, a support widget, a backend, a non-React stack, no chat space at all | Option D, this page |
Option D needs no identity app chat, no seat for an actor and no bridge. It is a request and a response.
1. Save a profile
A profile is the scope and the decision settings every turn runs under. Only organization admins and owners may save or read one (organization_admin_required otherwise).
start_workflow("dgi.chat.profile.save", {
"name": "Orders",
"allowed_workflow_types": ["acme.order.*"],
"suggestions": [{"label": "My orders", "prompt": "list my open orders"}],
"locale": "en"
})
→ {"profile_uuid": "7c1e…", "profile": {…}, "created": true, "set_by_user_uuid": "…", "set_at": "…"}
| Field | Values and default | What it does |
|---|---|---|
profile_uuid | Optional | Update this profile. Omitted: a new one is created |
name | Up to 120 characters | A label for you |
allowed_workflow_types | Required. 1 to 50 names, exact or prefix.* | The only workflows DGI may start, intersected with what the caller may run. An empty list is refused, and a bare * is not a workflow type |
decision_engine | jev (default), auto, llm | jev: Jev decides. auto: Jev when it is confident, else the LLM. llm: the LLM answers first, which is itself an opt-in to the LLM |
llm_fallback | Boolean, default false | The switch for the LLM. Off: a turn Jev cannot decide comes back as chips. On: DGI's LLM answers every fallback reason except those in llm_fallback_handoff |
llm_fallback_handoff | Up to 20 reason names, default [] | With llm_fallback: true, the fallback reasons that still come back as chips instead of an LLM answer. Matched exactly, or by family (jev covers jev_timeout) |
suggestions | Up to 8 {label, prompt} | Your chips. A chip sends its label as the next message |
default_inputs | {workflow_type: {field: value}} | Values DGI pre-fills. Same limits and rules as in the responder reference |
locale | pt-BR, en, es | The language of fixed texts such as refusals. A turn can override it |
prompt | Up to 8,000 characters | Instructions added to DGI's own: tone, language, what to show |
max_reasoning_turns | 6 to 48, default 12 | Tool-loop budget per turn on the LLM path |
renderers | A subset of the renderers in the catalog. Default: all | Which cards the profile may return |
jev_connection_uuid | An active TypeSafe connection | Which connection Jev decides with. Without it, DGI uses the organization's own |
jev_threshold | 0.5 to 0.99 | Jev confidence needed to act |
ai_provider_config_uuid | An AI provider configuration of the organization | Which model answers when the LLM is used. Your organization pays its own tokens |
Each save stores the whole profile. Send every field you want to keep. dgi.chat.profile.get with {"profile_uuid": "…"} returns profile_uuid, profile, set_by_user_uuid and set_at.
llm_fallback existed, with a non-empty llm_fallback_handoff, still reads as llm_fallback: true. Set the flag explicitly the next time you save it.2. Send a turn
start_workflow("dgi.chat.turn", {
"profile_uuid": "7c1e…",
"message": "create an order for Ana, 2 units",
"conversation_uuid": "3f9a…",
"locale": "en"
})
| Input | Meaning |
|---|---|
profile_uuid | Required. The profile to run under |
message | Required. What the person wrote |
conversation_uuid | Optional. Continue this conversation. Omitted: a new conversation is created and returned |
locale | Optional. pt-BR, en or es. Default: the profile's |
Every turn and every answer returns the same shape:
| Output | Meaning |
|---|---|
conversation_uuid | Keep it and send it with the next turn |
text | The answer, in markdown. It always stands on its own, even when there is a card |
ui | One card, or null. See the card catalog |
suggestions | Up to 5 chip labels. A chip sends its label verbatim as the next message |
decision_path | How the turn was decided: jev:<fast path>, deterministic:<fast path>, llm, llm_fallback:<reason>, deterministic:view or deterministic:refused |
duration_ms | Turn wall time |
ui_refusal | Only on dgi.chat.respond, when the answer was refused |
A conversation belongs to its caller: a member's own user, or the end user of your app. Anyone else gets conversation_not_found.
3. Answer the card
When the person acts on a card, send only its ui_id, the action and the values:
start_workflow("dgi.chat.respond", {
"conversation_uuid": "3f9a…",
"ui_id": "0b6f…",
"action": "confirm"
})
action | On | values |
|---|---|---|
submit | form, query console | {field: value}. A query console sends {statement} |
confirm | confirm | None. The server runs its stored proposal |
cancel | form, confirm | None |
edit | confirm, when offered | The changed inputs. DGI proposes again on a new card |
select | select, table, actionlist, composition, and the views that open something | {value, label?, prompt?, row? or item?}. On the newer views: {node} (dag, schema), {item} (timeline), {related} (detail), {saved} (query console) |
refresh | a read card | None. The read runs again with the same inputs, no reasoning |
row_action | table or datagrid with row actions | {row, action}, indices into the card's rows and render.row_actions. A datagrid adds the row's ref |
query | datagrid, query console, a card with view controls | {sort?, filters?, cursor?} or {params}. See view controls |
Prompts (form, confirm, select) are answerable for 30 minutes. Read cards stay refreshable for 7 days, at most one refresh every 10 seconds. This API never offers live or stop: your client refreshes when it wants fresh data.
Refusals
A refused answer comes back as a completed turn with ui_refusal, a short localized text, ui: null and decision_path: "deterministic:refused". Nothing refused ever reaches DGI, and a refusal consumes nothing.
ui_refusal | Why |
|---|---|
ui_unknown | No such card in this conversation, or it was replaced by a newer one |
ui_consumed | The form or confirm was already answered |
ui_expired | More than 30 minutes passed |
ui_wrong_author | The card was issued to someone else |
ui_action_not_offered | The card does not offer that action |
ui_field_unknown, ui_field_required, ui_field_invalid | A value does not match the stored card's fields, options or controls |
ui_response_malformed | The answer is not well formed |
ui_rate_limited | A refresh within 10 seconds, or a query within 2 seconds, of the last one |
ui_action_tampered | The stored proposal no longer matches the hash it was issued with. It is never confirmed |
card_scope_changed | The card's read left the profile's scope |
Jev first, then chips
Every turn wraps DGI's own thought processing. Jev, the typed decision engine, answers first: is this a read, a form, a proposal, and which workflow. Most turns end there, with a card built in code from DGI's typed output. No model text ever becomes a card.
When Jev cannot decide:
| Profile | What comes back |
|---|---|
llm_fallback: false (default) | A short text and chips: the profile's suggestions, or else the slash commands of the profile's scope. No LLM runs. decision_path is llm_fallback:<reason> |
llm_fallback: true | DGI's LLM answers, except for the reasons listed in llm_fallback_handoff, which still come back as chips |
decision_engine: "llm" | The LLM answers first. llm_fallback_handoff still names the reasons that come back as chips |
A chip is not a special action. Tapping one sends its label as an ordinary dgi.chat.turn message, so the next turn usually lands on a Jev fast path.
Security
- Scope pinned. DGI may start only the profile's
allowed_workflow_types, intersected with what the caller may run. An empty scope is refused (dgi_scope_required). - The caller is the principal. The principal comes from the token: a member runs as themselves, and an end user runs in DGI's end-user lockdown, limited to workflows your app exposes to end users. A run with no person behind it (a system trigger, an agent with no user) is refused with
caller_principal_required. - The stored card is the only authority. Every answer is checked against the server's copy of the card: its fields and options, its author, its expiry and its pending action.
- Consume once. A form or confirm is consumed once, under the conversation's row lock. The same answer sent twice is refused the second time.
- Confirm by hash. A proposal carries
action_input_hash = sha256(canonical_json(input)), the SHA-256 of the input as JSON with sorted keys, no whitespace, UTF-8. A confirm runs the stored proposal with that hash. Values sent with a confirm are ignored, and Edit produces a new proposal with a new hash on a new card. - 30-minute expiry. Forms, confirms and selects expire 30 minutes after they are issued.
- Opaque refs. Rows, nodes, tables and saved queries carry refs such as
r1orsq1. What each stands for stays on the server.
The same rules hold in the Buzz chat. Read Security model for the full list.
Example: curl
API="https://workflow-api.orkestia.dev"
AUTH="Authorization: Bearer $TOKEN"
start() { # start a workflow and wait until it ends
wid=$(curl -s -X POST "$API/api/workflows/start" -H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"workflow_type\": \"$1\", \"initial_data\": $2}" | jq -r .workflow_id)
until curl -s "$API/api/workflows/$wid" -H "$AUTH" | jq -e '.is_terminal' >/dev/null; do sleep 1; done
curl -s "$API/api/workflows/$wid" -H "$AUTH" | jq .state_data
}
# 1. An admin saves the profile once (Jev only, LLM off).
start dgi.chat.profile.save '{"name": "Orders", "allowed_workflow_types": ["acme.order.*"],
"suggestions": [{"label": "My orders", "prompt": "list my open orders"}], "locale": "en"}'
# -> {"profile_uuid": "7c1e…", ...}
# 2. A turn.
start dgi.chat.turn '{"profile_uuid": "7c1e…", "message": "create an order for Ana, 2 units"}'
# -> {"conversation_uuid": "3f9a…", "text": "Create an order for Ana?",
# "ui": {"v": 1, "ui_id": "0b6f…", "renderer": "confirm", "title": "Create order for Ana",
# "pending_action": {"workflowType": "acme.order.create", "input": {"name": "Ana", "qty": 2},
# "action_input_hash": "9d2c…", ...},
# "actions": ["confirm", "cancel", "edit"], "expires_at": "…"},
# "decision_path": "jev:write_propose", "duration_ms": 640, "suggestions": []}
# 3. The person presses Confirm: only the ui_id and the action travel.
start dgi.chat.respond '{"conversation_uuid": "3f9a…", "ui_id": "0b6f…", "action": "confirm"}'
# -> {"text": "Order A-17 created.", "ui": {"renderer": "run", ...},
# "decision_path": "deterministic:confirm_execute", ...}
# 4. The same answer again is refused.
start dgi.chat.respond '{"conversation_uuid": "3f9a…", "ui_id": "0b6f…", "action": "confirm"}'
# -> {"ui_refusal": "ui_unknown", "text": "That form is no longer active. Please ask again.", "ui": null,
# "decision_path": "deterministic:refused", ...}
acme.order.* stands for your own workflows. Instead of polling, you can follow a run with the SSE stream GET /api/workflows/{workflow_id}/stream (see API and tooling).
Example: Python
import time
import requests
API = "https://workflow-api.orkestia.dev"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
def run(workflow_type: str, initial_data: dict) -> dict:
started = requests.post(f"{API}/api/workflows/start", headers=HEADERS,
json={"workflow_type": workflow_type, "initial_data": initial_data}).json()
while True:
state = requests.get(f"{API}/api/workflows/{started['workflow_id']}", headers=HEADERS).json()
if state.get("is_terminal"):
return state["state_data"]
time.sleep(0.5)
turn = run("dgi.chat.turn", {"profile_uuid": PROFILE_UUID, "message": "my open orders"})
card = turn["ui"]
if card and card["renderer"] == "form":
turn = run("dgi.chat.respond", {"conversation_uuid": turn["conversation_uuid"], "ui_id": card["ui_id"],
"action": "submit", "values": {"status": "open"}})
elif card and "refresh" in card.get("actions", []):
turn = run("dgi.chat.respond", {"conversation_uuid": turn["conversation_uuid"], "ui_id": card["ui_id"],
"action": "refresh"}) # reruns the read with no reasoning
print(turn["text"], turn["suggestions"], turn["decision_path"])
TOKEN is a member's token, or your end user's own token from Sign in with Orkestia. Never put an organization token or API key in a browser or mobile bundle.
The card contract (JSON Schema)
The ui object, the dgi.chat.respond input and the turn output are described by one JSON Schema (draft 2020-12), chat_ui.v1_3.json, with the id https://orkestia.dev/schemas/dgi/chat_ui.v1_3.json. Its rules for a client:
- Required keys:
v(always1),ui_id(32 hex),renderer,title,expires_at. - One of:
pending_inputfor a form,pending_actionfor a confirm,renderfor everything else. Read cards may also carryactions,context(up to 8 chips of labels) andcontrols. - Ignore what you do not know. Revisions add optional keys and renderers only. An unknown renderer is drawn as the turn's
text. - Nothing in it is authority. The server checks every answer against its own stored copy, so a client decides how a card looks, never what is accepted.
The card catalog lists every renderer, its fields and the answers it sends.
Ask your AI assistant
Using https://docs.orkestia.dev/raw/chat/option-d-chat-api.md, draft a dgi.chat.profile.save call for a support widget that may search and open tickets, with Jev only and three chips. Do not start it.
Write a TypeScript client for dgi.chat.turn and dgi.chat.respond that draws form, confirm and table cards and falls back to the text for any other renderer.
Explain what my widget should show for each ui_refusal code of dgi.chat.respond.
For AI agents
| Rule | Detail |
|---|---|
| Discover | list_workflow_types(prefix="dgi.chat."), then get_workflow_schema on each before proposing a call |
| Profiles | dgi.chat.profile.save and dgi.chat.profile.get need an organization admin or owner. Prepare the call; a person starts it. Each save replaces the whole profile |
| Scope | Never propose an empty allowed_workflow_types or a bare * |
| LLM | Off by default. Do not turn on llm_fallback unless the user asks for it: it spends the organization's tokens |
| Confirms | Only dgi.chat.respond with action: "confirm" on a stored confirm card runs a write. Never treat a typed "yes" as a confirm, and never send values with it |
| Principal | Never pass organization_uuid or a user id: the token decides who the turn runs as |
Structured chat security model
Who DGI runs as in chat, what it may reach, why a typed yes never confirms, how cards are protected against forgery and replay, and who pays for the tokens
Card catalog
Every structured chat card DGI can return, what it shows and what the person can do with it. Forms, confirms with a diff, tables, charts, KPI tiles, logs, runs, links, compositions, documents, and the DAG, schema, datagrid, query console, diff, detail and timeline views, plus view controls and the query action
