Orkestia
Blog
Chat

Option D: any app or API (dgi.chat)

Put DGI's structured chat in any web app, mobile app, support widget or backend with four workflows. Save a profile, send a turn, answer the card, and read the card contract as JSON Schema. Jev first, the LLM off by default, and every write behind a stored confirm

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 calls dgi.chat.turn for every message and dgi.chat.respond for every answer to a card. dgi.chat.profile.get reads a profile back.
  • Same cards as the chat. The ui object is the chat wire contract, v1.3 (block version v is still 1). Buzz posts the very same object inside its buzz-ui fence. Every card is listed in the card catalog.
  • Jev first, the LLM off by default. A new profile decides with decision_engine: "jev" and llm_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/start and read the run until it ends, or use the MCP tools start_workflow and watch_workflow. The organization comes from the token.

When to use it

You haveUse
An Orkestia chat space and the hosted page fitsOption A
A React app that should show the Orkestia chatOption B
A chat client of your own on the relayOption C
Any other surface: a web or mobile app, a support widget, a backend, a non-React stack, no chat space at allOption 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": "…"}
FieldValues and defaultWhat it does
profile_uuidOptionalUpdate this profile. Omitted: a new one is created
nameUp to 120 charactersA label for you
allowed_workflow_typesRequired. 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_enginejev (default), auto, llmjev: 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_fallbackBoolean, default falseThe 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_handoffUp 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)
suggestionsUp 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
localept-BR, en, esThe language of fixed texts such as refusals. A turn can override it
promptUp to 8,000 charactersInstructions added to DGI's own: tone, language, what to show
max_reasoning_turns6 to 48, default 12Tool-loop budget per turn on the LLM path
renderersA subset of the renderers in the catalog. Default: allWhich cards the profile may return
jev_connection_uuidAn active TypeSafe connectionWhich connection Jev decides with. Without it, DGI uses the organization's own
jev_threshold0.5 to 0.99Jev confidence needed to act
ai_provider_config_uuidAn AI provider configuration of the organizationWhich 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.

A profile saved before 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"
})
InputMeaning
profile_uuidRequired. The profile to run under
messageRequired. What the person wrote
conversation_uuidOptional. Continue this conversation. Omitted: a new conversation is created and returned
localeOptional. pt-BR, en or es. Default: the profile's

Every turn and every answer returns the same shape:

OutputMeaning
conversation_uuidKeep it and send it with the next turn
textThe answer, in markdown. It always stands on its own, even when there is a card
uiOne card, or null. See the card catalog
suggestionsUp to 5 chip labels. A chip sends its label verbatim as the next message
decision_pathHow the turn was decided: jev:<fast path>, deterministic:<fast path>, llm, llm_fallback:<reason>, deterministic:view or deterministic:refused
duration_msTurn wall time
ui_refusalOnly 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"
})
actionOnvalues
submitform, query console{field: value}. A query console sends {statement}
confirmconfirmNone. The server runs its stored proposal
cancelform, confirmNone
editconfirm, when offeredThe changed inputs. DGI proposes again on a new card
selectselect, 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)
refresha read cardNone. The read runs again with the same inputs, no reasoning
row_actiontable or datagrid with row actions{row, action}, indices into the card's rows and render.row_actions. A datagrid adds the row's ref
querydatagrid, 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_refusalWhy
ui_unknownNo such card in this conversation, or it was replaced by a newer one
ui_consumedThe form or confirm was already answered
ui_expiredMore than 30 minutes passed
ui_wrong_authorThe card was issued to someone else
ui_action_not_offeredThe card does not offer that action
ui_field_unknown, ui_field_required, ui_field_invalidA value does not match the stored card's fields, options or controls
ui_response_malformedThe answer is not well formed
ui_rate_limitedA refresh within 10 seconds, or a query within 2 seconds, of the last one
ui_action_tamperedThe stored proposal no longer matches the hash it was issued with. It is never confirmed
card_scope_changedThe 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:

ProfileWhat 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: trueDGI'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 r1 or sq1. 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 (always 1), ui_id (32 hex), renderer, title, expires_at.
  • One of: pending_input for a form, pending_action for a confirm, render for everything else. Read cards may also carry actions, context (up to 8 chips of labels) and controls.
  • 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

prompts
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

RuleDetail
Discoverlist_workflow_types(prefix="dgi.chat."), then get_workflow_schema on each before proposing a call
Profilesdgi.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
ScopeNever propose an empty allowed_workflow_types or a bare *
LLMOff by default. Do not turn on llm_fallback unless the user asks for it: it spends the organization's tokens
ConfirmsOnly 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
PrincipalNever pass organization_uuid or a user id: the token decides who the turn runs as