Living Surfaces
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.ticktakes 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 asurface.patchframe, resumable byseq. - 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
| Part | Meaning |
|---|---|
intent | What the surface is for, up to 200 characters. DGI may rewrite it |
status | live (DGI is still shaping it), stable (it stopped changing), crystallized (saved as a composition), retired |
rev | The revision. Every stored patch adds one |
nodes | Containers (root, grid, stack, region, each with children) and blocks. A block holds one card and its source: the workflow, its input and the view |
policy | The 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
| Workflow | Input | Output |
|---|---|---|
dgi.surface.create | intent?, policy (required), seed?, origin? (user or event), heartbeat? (default true) | surface_uuid, surface, rev, heartbeat_schedule_uuid, heartbeat_status |
dgi.surface.tick | surface_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.signal | surface_uuid, signal, tick_now? | queued, tick_workflow_ref, heartbeat_status |
data.dgi.surface.get | surface_uuid, since_seq?, limit? (up to 100) | surface, timeline |
data.dgi.surface.list | status?, limit? (up to 100) | surfaces, count |
dgi.surface.archive | surface_uuid | status: "retired", rev, seq, heartbeat_status |
dgi.surface.crystallize | surface_uuid, name?, action_input_hash? | status (proposed or crystallized), proposal, component_descriptor, composition_uuid, crystallized_as, validation |
dgi.surface.touch | surface_uuid | last_viewed_at, heartbeat_status |
dgi.surface.apply | surface_uuid, if_rev, ops, why? | rev, seq, accepted, refused |
data.dgi.surface.row | surface_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:
- Signals. Apply what happened: an intent, a dismiss, a dwell, a retire, crystallize or resolve, a restore, a confirm.
- Refresh. Read every card again. A card is replaced only when what it shows changed.
- Seed. An empty surface with an intent gets its first card.
- 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.
- Decide. Jev first, then the LLM when the policy allows it.
- Schema gate. Invented fields are dropped, identity fields stripped, and a read missing a required field is refused.
- 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_budgetstructural 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.
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:
kind | Shape | Use |
|---|---|---|
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_action | The 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:
- Call
dgi.surface.crystallizeonce. It returnsstatus: "proposed", theproposal(the cards' reads as one composition layer), acomponent_descriptorof the layout, and the proposal'saction_input_hash. - Call it again with that hash. The platform validates the stored proposal with
composition.validate, saves it withcomposition.save, recordscrystallized_as(virtual.<uuid>@<version>) and stops the heartbeat.
Publishing the layout as an app is not part of crystallize yet.
Policy
| Field | Values and default | What it does |
|---|---|---|
allowed_workflow_types | Required. 1 to 50 names, exact or prefix.* | The only workflows the cards may read, intersected with what the viewer may run |
mutation_budget | 1 to 6, default 2 | Structural ops (add, remove, move) per tick |
renderers | kpi, chart, table, logs, run, confirm and the newer views (dag, schema, datagrid, query, diff, detail, timeline). Default: kpi, chart, table, run, confirm | Which cards the surface may show |
max_blocks | 1 to 12, default 6 | Cards on the surface at once |
llm_fallback | Boolean, default false | Off: Jev only. On: the LLM decides when Jev is not confident |
llm_budget | 0 to 20, default 20 | LLM calls for the whole life of the surface. It can lower the cap of 20, never raise it |
model | A model id | Pins the model the fallback uses |
jev_threshold | 0.5 to 0.99, default 0.8 | Jev confidence needed to decide |
jev_connection_uuid | A TypeSafe connection | Which connection Jev decides with |
chart_top_n | 1 to 60, default 12 | Bar and pie charts keep this many points and group the rest as "Other" |
heartbeat_seconds | 300 to 86,400, default 300 | The heartbeat interval. Never under 5 minutes |
idle_pause_seconds | 0 to 604,800, default 1,800 | With 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.touchwhenever someone opens the surface, or send an event withdgi.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):
type | Shape | Meaning |
|---|---|---|
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
seqyou applied asafter_seq, and every retained patch after it is replayed first.seqalways 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 withdgi.view.renderand the card's source. - Close codes.
4401authentication,4404a malformed surface id,4500a 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 assurface_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
refusedwith 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 auser_actionconfirm 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
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
| Rule | Detail |
|---|---|
| Discover | list_workflow_types(prefix="dgi.surface.") and prefix="data.dgi.surface.", then get_workflow_schema before proposing a call |
| Callers | Organization members only. End users are refused |
| Scope | Never propose an empty allowed_workflow_types |
| LLM | Leave llm_fallback off unless the user asks: it spends the organization's tokens |
| Writes | Never confirm for the user. A confirm is a user_action with the stored hash, in the owner's own tick |
| Confirm with the user first | dgi.surface.create (it registers a schedule), dgi.surface.apply, dgi.surface.archive, dgi.surface.crystallize with a hash |
| Safe to start | data.dgi.surface.get, data.dgi.surface.list, data.dgi.surface.row, dgi.surface.crystallize without a hash (it only proposes) |
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
What is DGI
Orkestia is Orkestia.dev. DGI is Orkestia's decision layer. It turns what a person asks into your organization's own workflows, and answers with live cards (forms, confirms, tables, charts) instead of prose. Where you can use it, what it does, and what it never does
