# Dex (https://condensate.dev/apps/dex/)

> Run Dex and drive your threads from a phone or desktop.

Dex is Condensate's browser app. It lists your threads, shows what each agent is doing, and lets you continue any of them from a phone, a tablet, a Mac or a desktop browser. A thread can run on Claude Code, Codex or Condensate's own agent, and Dex shows all of them the same way. It also reads pull requests, artifacts, account status and the machine's health from the same origin. This page covers starting Dex, its settings and addresses, the tray, keyboard shortcuts and notifications.

Start the runtime first, then run the web app:

```sh
bun run --cwd apps/dex-app dev
```

Open `http://127.0.0.1:5189`. The app is a Bun server. It serves the page and forwards the runtime, the review hub and the middleware, so the browser talks to one origin.

## Connections

| Setting | Default | What it does |
| --- | --- | --- |
| `DEX_APP_PORT` | `5189` | Port the app listens on |
| `DEX_APP_HOST` | `127.0.0.1` | Interface the app binds to |
| `CONDENSATE_AGENTS_URL` | `http://127.0.0.1:4741` | Runtime that `/runtime/*` is forwarded to |
| `CONDENSATE_HUB_URL` | `http://127.0.0.1:4700` | Review hub that `/hub/*` is forwarded to |
| `CONDENSATE_MIDDLEWARE_URL` | `http://127.0.0.1:4787` | Middleware that `/middleware/*` is forwarded to, and where the Metrics pane reads its panels |
| `CONDENSATE_KEY` | unset | Key the server presents to the middleware for the Metrics panels. Without it those reads answer 401 |

The web server forwards `/runtime/*`, `/hub/*` and `/middleware/*` with the prefix removed. Request bodies and event streams pass straight through, so live updates stay live. If an upstream is down, the app returns a 502 that names the service, for example "The runtime service is unavailable. Check its status in Box, then refresh." Check the Box section first when only part of the interface is missing.

Runtime chat and repository review have separate upstreams. Chat and tasks come from the runtime on 4741. Pull requests, review rooms and artifacts come from the [gt-review hub](https://condensate.dev/apps/review/) on 4700.

Other settings:

| Setting | What it does |
| --- | --- |
| `DEX_APP_HUB_REPO` | Repository the Work section reads when nothing else chooses one. Default `condensate` |
| `DEX_APP_THREAD_ID` | Thread the app opens with. Unset means the newest workspace head thread, or the Agents section when there is none |
| `DEX_APP_GTR_MCP` | Path to the hub's MCP entry on the runtime's host. Set it when the runtime runs on another machine or in a container |
| `DEX_APP_OPENOBSERVE_URL` | Address the Metrics pane's links use for OpenObserve, when a reader reaches it by another name |
| `DEX_APP_PET_PACKS_DIR` | Folder of pet packs served read-only under `/pets/<pack>/`. Unset serves none |
| `DEX_APP_PUSH_FILE` | Where the push keys and subscribed devices are kept. Default `~/.condensate/dex-app-push.json` |
| `DEX_APP_BUNDLE_FILE` | A prebuilt page bundle to serve instead of building one at startup, used by container images |
| `DEX_APP_DEV_BUNDLE` | `1` builds React's development bundle, for readable warnings while you work on the page |
| `CONDENSATE_BUILD_SHA` | The commit this page was built from. The Box section compares it with the runtime's commit and names a skew. Unset means the page reports no commit |
| `DEX_HOST_INGEST_MCP_DIR` | Folder on the runtime's host that holds the ingest MCP servers. A new Chat thread gets its `x-mcp.ts`. Unset starts Chat without it |

`?thread=<id>` in the address overrides `DEX_APP_THREAD_ID`.

## Addresses

Every screen has an address, so a link or a reload lands where you were.

| Address | Opens |
| --- | --- |
| `/` | The thread card, with the chat |
| `/?thread=<id>` | A specific thread |
| `/pr/<repo>/<number>` | One pull request's page. Add `#<path>:L<line>` to open at a line |
| `/?pr=<owner/repo#number>` | The review pane for one pull request, with no thread behind it |
| `/review/<repo>` or `/review/<repo>/<number>` | Review mode for a repository, optionally on one pull request |
| `/repo/<name>` | Every open pull request on the repository, grouped as stacks |
| `/artifacts/<id>` | One artifact. `/artifacts/<id>/raw?repo=<name>` shows the document alone, for sharing |
| `/timeline` | Deploys, their pull requests, review rooms and threads. `?thread=<id>` traces one thread |
| `/metrics` | The machine's metrics on their own page |

An address Dex does not recognise opens the thread card, so a stale link opens the app rather than an empty page.

## Deep links

An agent names a Condensate thing as an ordinary markdown link to its `cnd:` address, such as `[#2253](cnd:pr/condensate/2253)`.
The SDK's `Ref` builds the address, and code mode exposes it as `ref`.
A surface resolves the address with the SDK's `resolve`: Dex opens its own page, the iOS app opens `condensate://` for a pull request or a thread and Dex's web page (`<dex>` below) for the rest, and anything else opens the outside page.
A kind with no page on a surface opens its outside page instead, and shows its label as plain text when it has none, so no link is dead.
Loose mentions such as `#2253`, `owner/repo#N`, a GitHub pull request URL, `@seat:<8 hex>`, `bug_<id>` and `path/to/file.ts:12` are linked the same way, except inside code, an existing link or a link definition, or after a backslash.
Two cases are read as text, so a mention in them is linked: a fence opened on a list item's marker line, and a definition inside a list item or with its label alone on its line.
A served repository's `#N` opens Dex's pull request page without checking the number; that page says `No pull request #N in <repo>` when none carries it.

| Kind | Write | Dex | iOS | Outside |
| --- | --- | --- | --- | --- |
| pr | `[label](cnd:pr/condensate/2253)` | `/pr/condensate/2253` | `condensate://pr/condensate/2253` | `https://github.com/<owner>/condensate/pull/2253` |
| issue | `[label](cnd:issue/condensate/12)` | none | none | `https://github.com/<owner>/condensate/issues/12` |
| thread | `[label](cnd:thread/%3Cthread-id%3E)` | `/?thread=%3Cthread-id%3E` | `condensate://thread/%3Cthread-id%3E` | none |
| deploy | `[label](cnd:deploy/fae2b520c)` | `/timeline` | `<dex>/timeline` | none |
| file | `[label](cnd:file/apps%2Fdex-app%2Fsrc%2Froutes.ts/332)` | none | none | `https://github.com/<owner>/condensate/blob/HEAD/apps/dex-app/src/routes.ts#L332` |
| run | `[label](cnd:run/%3Crun-thread-id%3E)` | `/?thread=%3Crun-thread-id%3E` | `condensate://thread/%3Crun-thread-id%3E` | none |
| room | `[label](cnd:room/condensate/2253)` | `/?pr=condensate%232253` | `<dex>/?pr=condensate%232253` | `https://github.com/<owner>/condensate/pull/2253` |
| bug | `[label](cnd:bug/bug_%3Cid%3E)` | none | none | none |
| account | `[label](cnd:account/%3Caccount%3E)` | none | none | none |
| workspace | `[label](cnd:workspace/condensate)` | `/repo/condensate` | `<dex>/repo/condensate` | none |
| model | `[label](cnd:model/%3Cmodel-id%3E)` | none | none | none |
| artifact | `[label](cnd:artifact/%3Cartifact-id%3E/condensate)` | `/artifacts/%3Cartifact-id%3E?repo=condensate` | `<dex>/artifacts/%3Cartifact-id%3E?repo=condensate` | none |
| doc | `[label](cnd:doc/use)` | none | none | `<docs>/use/` |
| linear | `[label](cnd:linear/ABC-123)` | none | none | `https://linear.app/<linear-org>/issue/ABC-123` |
| notion | `[label](cnd:notion/%3C32-hex-page-id%3E)` | none | none | none |
| ask | `[label](cnd:ask/%3Cthread-id%3E/%3Cask-id%3E)` | `/?thread=%3Cthread-id%3E&ask=%3Cask-id%3E` | `<dex>/?thread=%3Cthread-id%3E&ask=%3Cask-id%3E` | none |
| question | `[label](cnd:question/%3Cworkspace%3E/%3Cthread-id%3E/%3Crequest-id%3E/3)` | `/?thread=%3Cthread-id%3E&workspace=%3Cworkspace%3E&question=%3Crequest-id%3E&generation=3` | `<dex>/?thread=%3Cthread-id%3E&workspace=%3Cworkspace%3E&question=%3Crequest-id%3E&generation=3` | none |

Hold the pointer over a pull request or thread link, or press and hold it on a phone, to see a card read live: a pull request's title, checks, verdict and needs-noah label, or a thread's status and last activity.
A card that cannot read says so.

## Open a task

Click the conversation title, or press `⌘K` (`Ctrl+K` without a Command key). The command palette opens with the open thread's actions, then New, Go to, Tools and Settings. Under Go to, pick a thread to continue its conversation.

The composer stays at the bottom of the chat. Everything else opens from a tray that rises over the chat from the composer, so the conversation stays one tap away.

## The tray and its sections

Press the tray, or use the keys below, to open the sections over the chat. They appear in this order after the chat:

| Section | What you find there |
| --- | --- |
| This thread | The open thread's own details |
| Agents | Every thread, with the head at the top and the rest grouped as needs you, running and idle. Parked tool calls have Approve and Deny on their row, and pressing a row opens that thread |
| Work | Pull requests for the repository, and the repository picker at the top |
| Artifacts | Documents agents wrote to the hub, newest first, read-only |
| Accounts | Connected provider accounts. A Claude sign-in asks you to paste a code. A Codex sign-in is approved on the provider's page |
| Box | The machine rather than the work: whether the runtime and the hub answer, the middleware's watcher, whether the page and the runtime were built from the same commit, running sessions, incidents, the setup checklist, settings, and the metrics |
| Decide | Pull requests waiting on you in every repository the hub serves, grouped by repository, one card each, with their checks, review state and evidence. Approve names the head it approves, and the title opens the pull request page |

The **Needs you** control in the header lists everything stopped on you. The oldest tool call has Allow and Deny. A question that offers choices shows them as buttons, and one without choices opens the thread.

The Work section's repository picker lists the repositories the hub serves. Your choice is remembered on this device. Pull request pages take their repository from the address, so a shared link keeps working whatever the picker shows.

## Keyboard

| Key | Action |
| --- | --- |
| `⌘K` or `Ctrl+K` | Open the command palette from anywhere |
| `⌘N` | Start a new task |
| `⌘M` | Open the model picker |
| `⌘↑` and `⌘↓` | Open the tray, or move through its sections |
| `⌘←` and `⌘→` | Move to the thread before or after this one |
| `⌘Enter` or `Ctrl+Enter` | Open or close the card from anywhere, including the composer |
| `⌘.` or `Ctrl+.` | Open the ask bar for this thread's waiting asks, with the keyboard on its first control |
| `⌘J` or `Ctrl+J` | Move to the next waiting ask |
| `Escape` | Close the ask bar without answering, or return to the chat |

The Mac Catalyst shell sends its New task and Search menu commands to the page. They do the same as `⌘N` and `⌘K`.

## Notifications

Dex tells you when an agent stops for you. The app badge and the tab title count everything that needs you, including asks, pull requests waiting on Decide, failing checks and incidents. The server sends a push notification for each ask and each review verdict.

| Surface | What it does | When it does not work |
| --- | --- | --- |
| Web push | One notification per ask, even on a locked phone | Permission refused, or an iPhone that has the page open in Safari instead of installed on the Home Screen |
| App badge | Shows the count on the app icon | A browser without the Badging API |
| Tab title | Shows the count, for example `(3) dex · <thread>` | Never. This is the fallback |

The server does the watching, because nothing in a closed browser is running. It watches the runtime's attention store and sends one push for each ask that parks. An ask that is already waiting when the server starts does not notify again. Review verdicts are checked about once a minute, because checking them more often adds load to the hub for little gain.

Permission is asked for from one line at the foot of the Needs you list, "Tell me on the phone when one stops", and only when you press Turn on. It is never requested at startup. On an iPhone or iPad in a Safari tab, the line asks you to add the app to the Home Screen instead. If you refuse, the line goes away and the badge and title keep counting. A device that already granted permission subscribes again by itself each time it opens, so a rotated push endpoint does not silently stop notifications.

A notification's press opens the thread on the exact ask.

## Installed app and offline use

The app can be installed to a Home Screen or dock. It caches its shell and its page code, so it opens quickly and shows a banner when it is offline.

Transcripts, the agent list, pull requests and the machine's status are never cached. Live state always comes from the network, so a cached shell does not mean the runtime is connected. If the app cannot reach the server when it opens, it shows a screen that says whether the device is offline or the server did not answer, with a Try again button.

## Troubleshooting

| Symptom | Check |
| --- | --- |
| A section shows an upstream error | The service named in the message, then the Box section |
| The Work list is empty or shows the wrong repository | `DEX_APP_HUB_REPO`, the picker, and whether the hub serves that repository |
| The Metrics panels answer 401 | `CONDENSATE_KEY` on the app server, and the middleware's own key |
| The head tools are missing | At startup the app checks that the head-tools entry exists beside the hub's MCP entry, and reports the result in the Box section. With `DEX_APP_GTR_MCP` set, that path is not checked |
| No push on an iPhone | Install the app to the Home Screen first. Safari in a tab does not deliver push |
