Orkestia
Blog
Chat

Living Surfaces

A page of live cards that DGI grows, keeps current and retires by itself. Create, tick, signal, read, list, archive and crystallize a surface with dgi.surface.*, set its policy, run its heartbeat, and follow its patch stream over a WebSocket

A Living Surface is a page that is a running decision loop, not a finished screen. You give DGI an intent ("keep the platform healthy", "incident room for the checkout outage") and a scope. DGI grows the page from cards, keeps each card current, adds the read the data points to next, removes what stopped serving the intent, and retires the page when the job is done. The cards are the same ones the chat draws: see the card catalog. Living Surfaces are part of DGI and are Alpha.

TL;DR

  • A surface is a tree of cards plus a policy. Containers (grid, stack, region) hold blocks, and each block is a chat card with the read that produced it. The policy says which workflows the cards may read and how much DGI may change per step.
  • One step is a tick. dgi.surface.tick takes signals (an intent, a click, a dwell, an event), refreshes every card, decides what to add, remove or wait for, validates the patch and stores it.
  • Jev first, the LLM off by default. Jev answers typed questions (add, remove or wait; which read; which view). The LLM runs only when the policy sets llm_fallback: true, within a budget.
  • A heartbeat keeps it alive. A schedule ticks every 5 minutes, pauses when nobody looks, and resumes on a view or an event.
  • Live patches over a WebSocket. Every stored patch is pushed on wss://stream.orkestia.dev/streaming/surface/{surface_uuid} as a surface.patch frame, resumable by seq.
  • Writes need a confirm. A write becomes a confirm card with a hash. It runs only when the owner confirms that hash in their own tick. A scheduled tick never writes.
  • Members only. End users are refused (surfaces_members_only).

What a surface is

surface  {uuid, intent, status, rev, root: "root", nodes, policy}
  root
  ├─ grid "Vitals"
  │   ├─ block  kpi    "Runs today"        source: audit.workflow-run.query
  │   └─ block  chart  "Failures by type"  source: audit.workflow-run.query, group_by
  └─ region "Open findings"
      └─ block  table  "Findings"          source: audit.finding.list
PartMeaning
intentWhat the surface is for, up to 200 characters. DGI may rewrite it
statuslive (DGI is still shaping it), stable (it stopped changing), crystallized (saved as a composition), retired
revThe revision. Every stored patch adds one
nodesContainers (root, grid, stack, region, each with children) and blocks. A block holds one card and its source: the workflow, its input and the view
policyThe scope and the limits below

Recipe, not data. The stored surface keeps the tree, each card's source and a signature of what it showed, never the card's data. Cards are read again, as the viewer, whenever they are shown. Row ids stay on the server.

Workflows

WorkflowInputOutput
dgi.surface.createintent?, policy (required), seed?, origin? (user or event), heartbeat? (default true)surface_uuid, surface, rev, heartbeat_schedule_uuid, heartbeat_status
dgi.surface.ticksurface_uuid (or a surface object), signals?, lang?decision_path (jev, llm or none), ops, why, continuation {mode}, retry_after_seconds, usage, refused, surface_uuid, rev, seq, persisted, heartbeat_status
dgi.surface.signalsurface_uuid, signal, tick_now?queued, tick_workflow_ref, heartbeat_status
data.dgi.surface.getsurface_uuid, since_seq?, limit? (up to 100)surface, timeline
data.dgi.surface.liststatus?, limit? (up to 100)surfaces, count
dgi.surface.archivesurface_uuidstatus: "retired", rev, seq, heartbeat_status
dgi.surface.crystallizesurface_uuid, name?, action_input_hash?status (proposed or crystallized), proposal, component_descriptor, composition_uuid, crystallized_as, validation
dgi.surface.touchsurface_uuidlast_viewed_at, heartbeat_status
dgi.surface.applysurface_uuid, if_rev, ops, why?rev, seq, accepted, refused
data.dgi.surface.rowsurface_uuid, ui_id, row, action?, kind?, lang?render, renderer, title, text

Never pass organization_uuid: it comes from the token (the heartbeat's scheduler stamps it on scheduled ticks).

Create

dgi.surface.create stores a new surface and registers its heartbeat. seed gives DGI cards to grow first: a list of reads {workflow_type, input, view: {kind, title, group_by?, chart?}}, or a shared genome. Every seed read must be inside the policy. An empty surface with an intent starts with a vitals card.

Tick

One tick, in order:

  1. Signals. Apply what happened: an intent, a dismiss, a dwell, a retire, crystallize or resolve, a restore, a confirm.
  2. Refresh. Read every card again. A card is replaced only when what it shows changed.
  3. Seed. An empty surface with an intent gets its first card.
  4. Decide whether to think. A tick thinks only after an intent, a user signal, an event, a data change or an add. It never thinks on an idle timer, and refresh failures alone never wake Jev or the model.
  5. Decide. Jev first, then the LLM when the policy allows it.
  6. Schema gate. Invented fields are dropped, identity fields stripped, and a read missing a required field is refused.
  7. Validate the patch. Every op passes four checks: structure, references (the policy's allowed_workflow_types), compatibility (the card render limits; a confirm carries its hash) and budget (mutation_budget structural ops per tick).

The result is stored as rev + 1 with one timeline entry, and published to the live stream. A tick whose revision is stale is refused, so two ticks never overwrite each other. why is one line saying why DGI did what it did, and retry_after_seconds is when it wants to look again.

Ops. add (a node under a parent), replace (a block's card), remove, move, commit, set_intent, set_status. add, remove and move are structural and count against mutation_budget; refreshes are free.

A tick with only a surface object and no surface_uuid is stateless: it uses the tree and policy you send, never calls the LLM, never writes and stores nothing. Create the surface first for persistence, the heartbeat, the LLM fallback and writes.

Signal

dgi.surface.signal queues a signal for the next tick, or starts one at once with tick_now: true. signal is one of:

kindShapeUse
event{kind: "event", source, payload?}Something happened outside, for example an alert
intent{kind: "intent", text}The owner changes what the surface is for
dwell{kind: "dwell", ui_id, ms}The viewer spent time on a card
user_action{kind: "user_action", ui_id, action, values?} with cancel, refresh or row_actionThe viewer dismissed, refreshed or acted on a card
restore{kind: "restore", intent, cards}Rebuild from a saved genome
timer{kind: "timer"}A plain wake-up

A confirm is never queued. It runs only in the confirming member's own dgi.surface.tick, as a user_action signal with action: "confirm" and values: {workflow_type, hash}. dgi.surface.tick also takes the query card action (sort, filter, page, controls) on its cards, the same as the chat.

Get and list

data.dgi.surface.get returns the surface (its recipe) and its timeline of decisions from since_seq. data.dgi.surface.list lists your surfaces and the ones visible to the whole organization, newest first, optionally by status. data.dgi.surface.row opens one row of a table card: the server resolves the row from its position, so the id never leaves it.

Archive

dgi.surface.archive retires a surface you own. Its heartbeat schedule is deleted and the record is kept.

Crystallize

When a surface stops changing, the owner can turn it into something permanent:

  1. Call dgi.surface.crystallize once. It returns status: "proposed", the proposal (the cards' reads as one composition layer), a component_descriptor of the layout, and the proposal's action_input_hash.
  2. Call it again with that hash. The platform validates the stored proposal with composition.validate, saves it with composition.save, records crystallized_as (virtual.<uuid>@<version>) and stops the heartbeat.

Publishing the layout as an app is not part of crystallize yet.

Policy

FieldValues and defaultWhat it does
allowed_workflow_typesRequired. 1 to 50 names, exact or prefix.*The only workflows the cards may read, intersected with what the viewer may run
mutation_budget1 to 6, default 2Structural ops (add, remove, move) per tick
rendererskpi, chart, table, logs, run, confirm and the newer views (dag, schema, datagrid, query, diff, detail, timeline). Default: kpi, chart, table, run, confirmWhich cards the surface may show
max_blocks1 to 12, default 6Cards on the surface at once
llm_fallbackBoolean, default falseOff: Jev only. On: the LLM decides when Jev is not confident
llm_budget0 to 20, default 20LLM calls for the whole life of the surface. It can lower the cap of 20, never raise it
modelA model idPins the model the fallback uses
jev_threshold0.5 to 0.99, default 0.8Jev confidence needed to decide
jev_connection_uuidA TypeSafe connectionWhich connection Jev decides with
chart_top_n1 to 60, default 12Bar and pie charts keep this many points and group the rest as "Other"
heartbeat_seconds300 to 86,400, default 300The heartbeat interval. Never under 5 minutes
idle_pause_seconds0 to 604,800, default 1,800With no viewer for this long, the heartbeat pauses

An invalid policy is refused with policy_invalid. The LLM path is also bounded per organization per UTC day, and each call is capped at 3,000 output tokens. Every decision's usage is recorded on the timeline.

Heartbeat

dgi.surface.create registers a schedule that runs dgi.surface.tick every 5 minutes (heartbeat_seconds, never under 300). The interval follows the tick's retry_after_seconds.

  • Pause. With no viewer for idle_pause_seconds (30 minutes by default), the schedule pauses.
  • Resume. Call dgi.surface.touch whenever someone opens the surface, or send an event with dgi.surface.signal.
  • Stop. Archive, retire and crystallize delete the schedule.
  • No writes. A scheduled tick never writes.

heartbeat_status in each output is active, paused, or a failure note when the schedule could not be registered. Pass heartbeat: false to dgi.surface.create for a surface you tick yourself.

The patch stream

Every stored patch is pushed to viewers over a WebSocket:

wss://stream.orkestia.dev/streaming/surface/{surface_uuid}?after_seq=<last seq you applied>

Authentication. Send your member access token in the Sec-WebSocket-Protocol header as ltinteg-bearer.<base64url(token)>, and offer ltinteg-surface.v1 too, which the server echoes back. Browsers can set this header through the protocols argument of new WebSocket(url, protocols), which keeps the token out of the URL. Only organization members hold such tokens, and the stream only ever reads your own organization's surfaces.

Frames (JSON):

typeShapeMeaning
hello{surface_uuid, organization_uuid, after_seq, latest_seq, at}Sent once. Compare latest_seq with the state you loaded
surface.patch{surface_uuid, seq, ops, why, rev?, at}One stored patch, in seq order
surface.resync{surface_uuid, after_seq, at}Patches after your after_seq were trimmed. Read the surface again with data.dgi.surface.get, then reconnect
heartbeat{at}Idle keep-alive
timeout{at}The socket reached its lifetime (one hour). Reconnect with after_seq and a fresh token
  • Resume. Pass the last seq you applied as after_seq, and every retained patch after it is replayed first. seq always increases per surface but can skip numbers. The stream keeps the newest 200 patches for 24 hours.
  • Renders are not in the frames. A patch carries each op in its timeline form, with a card's render replaced by its signature. On a replace, read that card again as yourself with dgi.view.render and the card's source.
  • Close codes. 4401 authentication, 4404 a malformed surface id, 4500 a server error.
const token = btoa(accessToken).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const ws = new WebSocket(
  `wss://stream.orkestia.dev/streaming/surface/${surfaceUuid}?after_seq=${lastSeq}`,
  [`ltinteg-bearer.${token}`, 'ltinteg-surface.v1']
)
ws.onmessage = (event) => {
  const frame = JSON.parse(event.data)
  if (frame.type === 'surface.patch' && frame.seq > lastSeq) {
    applyOps(frame.ops)   // your reducer
    lastSeq = frame.seq
  } else if (frame.type === 'surface.resync' || frame.type === 'timeout') {
    reloadAndReconnect()
  }
}

Event-born surfaces

An event binding can create a surface with origin: "event", an intent and a seed (the incident's reads). The binding's owner owns it, and every member of the organization can see it. A dgi.surface.signal event whose source is incident.resolved (or resolve, or whose payload has resolved: true) retires it on the next tick.

Security

  • Members only. An end-user principal is refused (surfaces_members_only). A surface is readable by its owner, or by every member of its organization when it is event-born. Anything else, including another organization's surface, reads as surface_not_found.
  • Scope pinned. Cards read only the policy's allowed_workflow_types, intersected with what the viewer may run, and are read again as the viewer.
  • The patch is the contract. Jev and the LLM only propose. Every op is validated before it is applied, and refused ops are returned in refused with the phase that refused them.
  • Writes need a confirm. A write row action becomes a confirm card carrying sha256(canonical_json(input)), and the proposal is stored on the server. It runs only when a user_action confirm names the same workflow and hash, in the owner's own tick. A hash that names no stored proposal is refused (ui_action_tampered). Typed text and model output never confirm.
  • Ids stay on the server. Row ids, row actions and confirm proposals never appear in the surface, the timeline or the stream.

surface_store_unavailable means surfaces are not enabled for your environment yet.

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. Create: Jev only (llm_fallback false), heartbeat every 5 minutes.
start dgi.surface.create '{"intent": "keep the platform healthy",
  "policy": {"allowed_workflow_types": ["audit.workflow-run.*", "audit.finding.*", "ticket.*"],
             "mutation_budget": 2, "renderers": ["kpi", "chart", "table", "run", "confirm"]}}'
# -> {"surface_uuid": "5f0c…", "rev": 0, "heartbeat_status": "active", ...}

# 2. Tick now (the heartbeat does the same every 5 minutes).
start dgi.surface.tick '{"surface_uuid": "5f0c…", "signals": [{"kind": "timer", "at": 0}]}'
# -> {"decision_path": "jev", "ops": [{"op": "add", "parent": "root", "node": {"kind": "block",
#       "block": {"renderer": "kpi", ...}, "source": {...}}}],
#     "why": "vitals first, no model needed", "continuation": {"mode": "continue"},
#     "rev": 1, "seq": 1, "persisted": true}

# 3. The viewer dismisses a card; an alert wakes the surface.
start dgi.surface.tick '{"surface_uuid": "5f0c…", "signals": [{"kind": "user_action", "ui_id": "ui-live-…",
  "action": "cancel", "at": 0}]}'
start dgi.surface.signal '{"surface_uuid": "5f0c…", "signal": {"kind": "event", "source": "sentry.alert"},
  "tick_now": true}'

# 4. Read it back, and open one table row (the row's id never leaves the server).
start data.dgi.surface.get '{"surface_uuid": "5f0c…", "since_seq": 0}'
start data.dgi.surface.row '{"surface_uuid": "5f0c…", "ui_id": "ui-live-…", "row": 0}'

# 5. Done with it.
start dgi.surface.archive '{"surface_uuid": "5f0c…"}'

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)


surface = run("dgi.surface.create", {
    "intent": "incident room",
    "policy": {"allowed_workflow_types": ["audit.*", "ticket.*"], "llm_fallback": True, "llm_budget": 10},
    "seed": [{"workflow_type": "audit.finding.list", "input": {"limit": 20},
              "view": {"kind": "table", "title": "Open findings"}}],
})
uuid = surface["surface_uuid"]
tick = run("dgi.surface.tick", {"surface_uuid": uuid})
for op in tick["ops"]:
    print(op["op"], op.get("node", {}).get("block", {}).get("title"))
print(tick["decision_path"], tick["why"], tick["usage"])

# A write proposed as a confirm card runs only when the owner confirms the stored hash in their own tick.
confirm = next((op["node"]["block"] for op in tick["ops"]
                if op["op"] == "add" and op["node"]["block"]["renderer"] == "confirm"), None)
if confirm:
    run("dgi.surface.tick", {"surface_uuid": uuid, "signals": [{
        "kind": "user_action", "ui_id": confirm["ui_id"], "action": "confirm", "at": 0,
        "values": {"workflow_type": confirm["pending_action"]["workflowType"],
                   "hash": confirm["pending_action"]["hash"]}}]})

Ask your AI assistant

prompts
Using https://docs.orkestia.dev/raw/chat/living-surfaces.md, draft a dgi.surface.create call for a surface that watches failed workflow runs and open tickets, Jev only, with at most 6 cards. Do not start it.

Show me my live surfaces with data.dgi.surface.list and explain the last three decisions on the newest one.

Write a browser client that opens the surface patch stream, resumes from the last seq, and reloads on surface.resync.

For AI agents

RuleDetail
Discoverlist_workflow_types(prefix="dgi.surface.") and prefix="data.dgi.surface.", then get_workflow_schema before proposing a call
CallersOrganization members only. End users are refused
ScopeNever propose an empty allowed_workflow_types
LLMLeave llm_fallback off unless the user asks: it spends the organization's tokens
WritesNever confirm for the user. A confirm is a user_action with the stored hash, in the owner's own tick
Confirm with the user firstdgi.surface.create (it registers a schedule), dgi.surface.apply, dgi.surface.archive, dgi.surface.crystallize with a hash
Safe to startdata.dgi.surface.get, data.dgi.surface.list, data.dgi.surface.row, dgi.surface.crystallize without a hash (it only proposes)