Build your own interface
Omniscio can run **your own interface** instead of its own. You tell it where your HTML file is; it gives that file read access to the same data its built-in screens use — your hubs, projects, sessions, messages, the needs-you list and your tags — a live feed of session changes, and the theme colours the app is drawing with right now. It gets **nothing else**: no files, no network, no way to change anything. Turn it on in **Settings → Appearance → Your own interface**. A bar at the top always stays, so you can get back to the normal interface even if your file is broken.
What it is
Omniscio normally has one interface. This feature lets you replace it with one of your own, built out of the same data — so an agent can arrange your work the way you actually think about it, instead of the way the app happens to be laid out. (Version 1 replaces the whole window; running your interface in a panel beside the normal one is not built yet.)
Four things go together:
- A read API at
/v1/ui— the data a UI needs, each object with one stable shape and a version. - A live feed — so a screen you build updates itself instead of re-reading on a timer.
- A slot — one place a file you wrote can run, sandboxed so it can only ask for the data above.
- The theme tokens — the app's own colours, sizes and fonts, so your interface can look like it belongs rather than like a page bolted on.
It is deliberately read-only. An interface built this way can show you things; if you want it to do things, it can call the same commands any agent uses (see Related).
Where to find it
To turn it on: Settings → Appearance → Your own interface. Two switches — run your own interface, and whether it takes the whole window — and a button that writes a working example for you to start from.
Your file lives here:
<your Omniscio data folder>/custom-ui/index.html
The card in Settings shows you the exact path on your machine, and the button writes the example there for you. Nothing else is required: no build step, no packaging, no upload. It is one HTML file, and everything — markup, styles and script — goes inside it.
If you do not want to touch HTML: ask an AI agent, in a normal Omniscio session, to write you one. Point it at this page. Everything it needs is here.
How it behaves
Your file is sandboxed, and the sandbox is real. It runs with no access to the app's own screens, no access to your files, and no access to the network — the app SERVES your document together with a rule that forbids outbound connections, so a page you (or an agent) wrote cannot quietly outbound connections before your document loads, so a page you (or an agent) wrote cannot quietly send your data anywhere. The only thing it can reach is the bridge described below.
Getting back out is always possible. If your interface takes the whole window, the app draws a bar across the top that your file cannot remove or cover, and it works even if your file's script has crashed. You can also get back from a terminal:
POST /custom-ui/exit
That turns full-window mode off. It is the recovery direction, so it needs no confirmation.
Two things worth knowing before you build:
- Read-only. Your interface cannot archive a session, send a message or spend money. Anything like that is a job for a normal command, not for the interface.
- A page on the web cannot call the read API. The API is local to your computer and deliberately sends no browser cross-origin permission, so only programs on your machine (your terminal, an agent, a small local app) can read it. Your sandboxed interface does not use the API directly — it goes through the bridge.
For agents
You are building an interface for someone. Everything below is the whole contract.
The sandboxed route — for the in-app slot
Your document talks to the app with postMessage. Two calls in, two kinds of message out. The marker
key amcCustomUi must be set to true and v must be 1, or your message is ignored.
Say hello as soon as your script runs. The app replies with the theme tokens.
parent.postMessage({ amcCustomUi: true, v: 1, kind: 'hello' }, '*')
Ask for data. Give each call an id of your own; the answer comes back with it.
parent.postMessage({
amcCustomUi: true, v: 1, kind: 'call', id: '1', method: 'sessions', params: {}
}, '*')
Listen for every message the app sends you:
window.addEventListener('message', (event) => {
const m = event.data
if (!m || m.amcCustomUi !== true || m.v !== 1) return // not for you
if (m.kind === 'result') { /* m.id, m.ok, m.data or m.error */ }
if (m.kind === 'tokens') { /* m.tokens: name -> value, m.mode: 'dark' | 'light' */ }
if (m.kind === 'event') { /* m.event.type === 'session-status' */ }
})
The methods you may call — all nine return exactly what /v1/ui returns, below.
method |
params |
you get back |
|---|---|---|
meta |
— | { apiVersion, appVersion, objects } |
hubs |
— | { hubs: [...] } |
projects |
— | { projects: [...] } |
sessions |
{ projectId?, limit? } |
{ sessions: [...] } |
session |
{ id } |
{ session } |
messages |
{ id, window? } |
{ messages: [...], capped } |
needs-you |
{ limit? } |
{ sessions: [...] } |
tags |
— | { tags: [...] } |
counts |
— | { sessions: {...}, projects, hubs, tags } |
Anything else is refused. There is no method that changes anything.
The theme tokens
On hello, and again whenever the theme changes, the app sends:
{ amcCustomUi: true, v: 1, kind: 'tokens', mode: 'dark', themeId: '...', tokens: { 'color-surface-100': '#...', 'accent': '#...', ... } }
Apply them as CSS custom properties on your own root and use them in your stylesheet. The names
published are the surface ladder color-surface-50 through color-surface-950 (plus an -rgb twin
for each), accent and accent-rgb, font-family-body, font-family-mono, app-font-size, and the
status colours as rgb triples, so you can use them at any opacity: status-agent-rgb,
status-needs-you-rgb, status-ready-rgb, status-error-rgb, status-question-rgb — and for
agent, needs-you and error, a -strong-rgb twin and a -fg-rgb colour to put ON top.
That is the whole list on purpose: it is the part that is promised to stay.
A name the app does not define is simply absent from the map — it is dropped rather than sent empty — so check for it before you use it.
The local API — for anything outside the app
Same data, same shapes, over HTTP on 127.0.0.1:19519, with a bearer token (the same one your other
commands use). Every response is { ok, apiVersion, data }.
GET /v1/ui/meta what this API is and what it publishes
GET /v1/ui/hubs
GET /v1/ui/projects
GET /v1/ui/sessions?projectId=&limit=
GET /v1/ui/sessions/:id
GET /v1/ui/sessions/:id/messages?limit=
GET /v1/ui/needs-you sessions waiting on a person
GET /v1/ui/tags
GET /v1/ui/counts what the sidebar and hub rows are built from
GET /v1/ui/stream live changes (see below)
Objects, exactly. These are the published shapes. A field not listed here is not published.
// hub
{ "key": "…", "name": "…", "kind": "…", "description": "…", "enabled": true }
// project
{ "id": "…", "name": "…", "color": null, "sessionCount": 3 }
// session (one shape, used by sessions / session / needs-you)
{ "id": "…", "name": "…", "projectId": "…", "projectName": "…", "projectColor": null,
"status": "…", "pendingAction": null,
"bucket": "live", // live | needs-you | interrupted | paused | archived
"startedAt": "…", "lastActiveAt": "…", "messageCount": 4 }
// message
{ "id": "…", "role": "user", "content": "…", "timestamp": "…" }
// tag
{ "id": "…", "name": "…", "description": null, "color": null, "icon": null, "showInSidebar": false }
// counts
{ "sessions": { "total": 0, "live": 0, "needsYou": 0, "interrupted": 0, "paused": 0, "archived": 0 },
"projects": 0, "hubs": 0, "tags": 0 }
bucket is the one to group by — it is the app's own answer to "where does this session belong",
so a list you build sorts the same way the normal sidebar does.
The live feed
GET /v1/ui/stream sends Server-Sent Events. Reconnect on close — each connection is a bounded
cycle, not a permanent stream, so closing is normal and your client should simply connect again.
event: ready— sent the moment you are connected. Wait for it: without it a quiet feed and a broken one look the same.event: session-status—{ "sessionId", "status", "previousStatus", "statusChangedAt", "at" }.event: cycle— the connection is ending cleanly; reconnect.
A bearer token is required, and this server deliberately does not accept a token in the URL, so
use a client that can send a header (fetch with a streaming body reader — not EventSource).
The worked example
The button in Settings writes a complete, working interface to your file: hubs, sessions grouped by bucket, live updates, and the theme applied. Read it, change it, or ask an agent to rewrite it. It uses only what this page documents — deliberately, so that if it works, so will yours.
Related
- Settings → Appearance — where the switches and the example button live.
- The local control server — the commands an agent can run, including the ones that change things, which this interface deliberately cannot.
Last verified 2026-10-12