← log
August 31, 2026BUILDING15 min read

Building a new UI for Oliver: The What, The Why, and the How

We replaced Oliver's dashboard with a single persistent conversation, treating the platform as a hierarchy of agents where one manager routes work to specialists.

Oliver has always been an organization of agents. The old UI rendered it as a board of projects. The new UI renders the organization itself.


The short version

We replaced Oliver's dashboard (a lifecycle Kanban of project cards, each with a chat behind it) with a single persistent conversation. You talk to one Oliver. That Oliver is the manager. Every project is its own agent with its own workspace, its own prompt files, and its own memory. Scheduled automations ("shadows") are standing work that Oliver reports on. The sidebar is not a list of places you go; it's the roster of who is working for you and what each of them knows.

The hierarchy itself is not new. It has been running in production for months: Slack users have been DMing the main agent and getting delegated work back the whole time. What's new is that the web UI now shows the hierarchy instead of hiding it behind a board.


The What

Before: a board of places

The old dashboard was a lifecycle Kanban: four stage columns (Organizing Thoughts, Building Reality, Polishing, Live) with an activity rail narrating "Oliver wrote 3 files in…". Cards moved between columns as a project advanced, and Oliver nudged you when it thought one was ready for the next stage. Clicking a card landed you in that project's chat: a centered conversation, a floating repository panel on the right edge, a preview pane you could swap in, and shadow overlays for scheduled work. Each project was a self-contained workroom, and the only way to talk to Oliver was to go stand in one of them.

It worked, but it made you the router. A quick question that doesn't belong to any project? Pick one anyway. Moving a file between projects meant opening the first, downloading, opening the second, uploading. Finding out what happened overnight meant clicking into each project. The management surface was scattered across N places, and the user was the thing tying them together.

After: one thread and a roster

The new shell is a messenger. Five destinations in the sidebar, in a fixed order that never reorders:

  1. Oliver: the main chat. Pinned first, forever. Never badges.

  2. Inbox: everything Oliver has to tell you that isn't a chat. Approvals, shadow outcomes, reminders, proactive notices. A ledger with a needs-you section pinned on top, not a feed.

  3. Projects: rows exist because Oliver worked. There is no create button. A row earns a second line only when there is something true to say: "Working on: …", "needs you", or a faint last-active time.

  4. Shadows: collapsed by default, muted, dashed ◇. Standing work on a schedule. Failures and approval gates bubble up to the Inbox.

  5. Memory: the quietest row. What Oliver knows about you, reviewable.

Settings is not a destination. It's a sheet that opens over the shell, so the live thread never unmounts.

Each project (/p/[id]) is a full workroom: the project's own thread plus a context panel (Files, Preview, Variables) that opens beside the conversation, so you can tell Oliver what to change while you look at it. Each shadow (/s/[id]) is a thread of runs: a step rail on the left, the selected run's story on the right. But you rarely start in either. You start in the Oliver thread, and Oliver routes you.


The Why

Oliver was already an org chart

The Kanban hid the actual structure of the system. On your droplet, Oliver is not one agent with a project dropdown. It's a hierarchy, and a literal one:

(The colors follow the UI's own grammar: navy is identity and structure, orange appears only where an agent is alive right now, dashed gray is standing work.)

The main agent knows you: your preferences, how you like to work, what's going on across the org, what its team has been doing. Each project agent knows its project: the repo, the files, the variables, the conversation history for that piece of work. Helpers are spun up by a project agent for a sub-task and know only that. A shadow is standing work attached to a project. It runs on that project's agent, in its own per-run session, but it's managed one level up: Oliver is the one who tells you what happened.

This is the shape of a real org: a chief of staff who delegates to specialists, each with their own desk. And the useful property of a real org is that context is partitioned on purpose. The specialist doesn't need your calendar preferences. The chief of staff doesn't need every file in every project. They need to know who to ask.

The Kanban rendered the wrong thing

A board of cards is the right visualization for a pipeline: things moving through stages. But Oliver's projects don't move through stages on their own; agents do work in them. The board showed the artifacts of the org and hid the org itself. Every time you clicked into a project to ask a question, you were walking into a specialist's office because the UI gave you no way to ask the chief of staff.

Framed that way, the messenger layout is not a style choice. You have one relationship, with Oliver. Oliver has N relationships, with project agents and their shadows. The UI should have exactly one primary thread and a roster, because that's the actual shape of the system.

Context lives somewhere, and the UI should say where

The governing rule we wrote down on day one: the UI is a visual adapter with zero intelligence of its own. Every element on screen renders agent-side truth from the droplet. We keep a file, agent-mapping.md, where every UI element has a row pointing at the agent-side state it renders, and updating the mapping in the same PR is a shipping gate. If a proposed element has no agent-side truth behind it, it's decoration and it gets cut.

That rule forces a specific layout. If context is partitioned by tier at runtime, the UI has to partition it the same way, or it's lying:

That's the whole information architecture: thread, card, panel. Four levels max. Depth 0 is where you live. Depth 1 threads are created by Oliver's work, never filed by you. Depth 2 panels are summoned and dismissed, never persistent state you manage. Depth 3 exists so power users can still reach the raw thing.

When you're in the Oliver thread, you're looking at what the manager knows. When you open a project, you're looking at what that agent knows. The URL tells you whose context you're standing in.

"Oliver manages for the user"

The one-line product principle under all of this: the user should never have to manage. Oliver manages for the user. Minimal by subtraction. Every micro-decision on this surface got tested against "does this add management burden?"

Places that bit:

  • No create button on Projects. Projects exist because Oliver did work. The + in the sidebar primes a message into the Oliver thread (/home?compose=…). It prefills, never sends, never opens a form.

  • Routing is just words. Type @Bed Research in the composer and that string is the payload. No chip object, no hidden project id. Oliver routes to the project because the sentence says so.

  • Upload-and-route. Drop a file on the home thread and say where it goes. You never navigate to the project first.

  • Delegation is async. Early on, ask_project held the main turn hostage. We watched "Oliver is thinking…" for three minutes while a delegated turn ran inside the main one. Now Oliver dispatches, replies immediately ("Bed Research is on it, I'll post here when it's done"), and the thread stays live.

  • Working state lives project-side. The in-stream delegation trace is one quiet line. The loud signal is the project's sidebar row: pulsing dot, "Working on: <task>". That's where the work is.

  • The thread is the record. Completed delegations never generate Inbox rows. Volume is solved inside the conversation, not by routing completions somewhere else you'd have to go check.


The How

"Project = agent" is literal

One OpenClaw gateway runs on your droplet. Every turn, the channel plugin sets two fields:

const accountId = isMain ? "main" : "project-" + projectId;
const agentId   = isMain ? "main" : "project-" + projectId;

agentId picks the agent directory, ~/.openclaw/agents/<agentId>/, which has its own SOUL.md, its own AGENTS.md / TOOLS.md, its own auth profiles, and its own session store. accountId is embedded in the session key. A project agent is a registered OpenClaw agent (openclaw agents add project-<id> --workspace … --agent-dir …) with a workspace at workspace/projects/project-<id>/. Main is just the agent whose id is literally main, with a root workspace holding SOUL.md, USER.md, IDENTITY.md, MEMORY.md, ORG_POLICY.md, ORG_KNOWLEDGE.md, re-asserted every main turn, content-hash gated.

Same process, different identity, different memory. The tool surfaces are partitioned to match: the main agent gets the projectless platform tools (list_projects, ask_project, create_project), and the project-scoped tools (manifest_*, repo_*, e2b_*) hard-refuse on the main lane with a steer toward ask_project. The manager can't edit your files. It has to ask the agent that owns them.

Ride the plumbing that already existed

The most consequential engineering decision was recognizing how little was new. The main-agent lane already ran end to end in production via Slack:

  • BridgeChatOptions.agentTarget: 'main' | 'project' already flowed web to bridge to droplet. A main-agent turn already produced a full SSE stream; Slack just discarded it. Web = pass the flag and render the stream.

  • The main-agent transcript was already in the DB. Every Slack DM turn writes chat_timeline_entries; the project history route simply filtered it out (NOT LIKE 'agent:main:%').

  • Delegation already existed: ask_project calls POST /api/internal/connectors/ask-project, which calls driveProjectTurn. Depth-2 helper visibility (sessions_spawn frames plus subagent_timeline) was already wired.

  • The chat components were healthy and portable: one renderer (conversation-timeline.tsx), one reducer, a project-agnostic ApprovalCard.

So "build a new UI" was mostly three jobs: give the main conversation a real schema home, extract the ~800-line message dispatcher out of the project page container into a scope-agnostic tested module, and build a persistent shell that hosts one renderer parameterized by scope.

Schema: the main conversation gets a real home

The first slice was a migration, not a hack. project_id went nullable on the session and timeline tables; an agent_target discriminator (project | main) and an origin column (chat | delegation | shadow | …) were added; the timeline cursor became a composite (created_at, seq), because the old 2000-entry hydration ceiling breaks on an unbounded thread.

Identity is deterministic all the way down. One durable home thread per member: uuidV5("<userId>:web-main"). One delegation conversation per (home thread, project): uuidV5("delegate:<mainConvId>:<projectId>"), which means repeated asks into the same project accumulate context rather than starting cold. The web home thread is distinct from the Slack DM thread by construction, so cross-surface double-delivery is structurally impossible.

The shell

Before this, the app had no persistent shell; every navigation was a full remount. The (shell) route group introduces one layout that stays mounted across /home, /p/[id], /s/[id], /inbox, and /memory, with a hoisted ChatProvider keyed by scope. Sidebar destinations are a typed intent union ({kind: "project", id}), and exactly one function turns intent into a path.

/home and /p/[id] are composed identically (provider, transport hook, header, state banner, timeline, composer) with different transports (useMainChat vs useProjectThread) and different @ mentionables (projects on home, files in a workroom). The project page adds what a workroom needs: drag-drop upload, the context panel, the in-flight banner. Home gets none of it. It's the funnel, not a workroom.

The context panel is URL state (?panel=files|preview|vars), a flex sibling of the thread above 1100px so the conversation reflows and stays live, an overlay below. Preview and Variables are the same modules the legacy project page mounts: composition, not invention. Files was the one substitution. The old repository panel positioned itself and brought its own header, so it couldn't compose, so it got a shell-native column instead.

The merged timeline: sessions are not a user-facing concept

A project's thread renders every chat_timeline_entries row the project ever produced, across all its sessions, in one chronological stream, backward-paged. A session is a kernel lifecycle unit, not a boundary the member should see. Delegation-origin sessions are included: a turn that Oliver delegated shows up in the project thread as a muted "Task from Oliver" bubble, so the project's record is complete. Shadow-run sessions are excluded; they live in the shadow's own thread.

This fixed a real bug on the way. The earlier approach adopted sessions[0], and a project with 8 sessions and 35 entries rendered 2.

Delegation: what happens when you ask something project-shaped

Four things worth calling out:

The trace is slim; the project shows the work. On the new register a running delegation isn't a card. It's a one-line marginal note hanging off Oliver's gutter: breathing dot, project label, one word (working / stopped / didn't finish), a mono m:ss counter. All three "working" signals (the sidebar row, the Now pill, the in-flight banner on the project page) read one aggregate endpoint, /api/agent/main-chat/delegations/active, which reads the same dispatch-time seed row the trace reads. One read, three surfaces, no new invention.

The watch is a poll, not a tail. The shell polls that aggregate on a 15s active / 60s idle cadence instead of holding a second SSE stream open per delegation. Cheaper, survives tab sleep, and settle is a server-owned event anyway. On the project page, a ~5s watch of the delegate session's timeline appends its rows into the workroom thread live while it runs.

Context of origin is structural, not prompted. A project agent needs to behave differently when Oliver delegates to it than when you talk to it directly; a delegated agent that asks clarifying questions is asking into the void. We deliberately did not solve this with a preamble on the delegation lane. The signals are in the substrate: a distinct delegate:<main>:<project> conversation id, origin: 'delegation' stamped on the session row, and a "user" message that is the main agent's task prompt. (The main web lane does carry a lean per-turn directive: "this conversation is project-less, ask_project returns immediately, don't poll." It's folded into the model's message but never persisted as a bubble.)

Stop means stop, at either tier. One route, two scopes. scope: main aborts the bridge's upstream fetch and the droplet turn, and reports three separable facts back (pipeStopped, cancelled, markerWritten). scope: delegation marks the card cancelled first, then stops the project agent's turn. Order matters, because the dispatch tail races and the last settle wins. The composer shows no Stop affordance when nothing is stoppable.

The settle turn: Oliver keeps the loop moving

This is the part that turned the new UI from a rendering change into a behavior change.

The first version of delegation follow-ups was mechanical: when a project agent finished, its full report was written verbatim into the home thread. Two problems, both visible the first time you watched it. Oliver was forwarding, not processing. And if you delegate a lot, the home thread becomes a work log wearing a conversation's clothes.

More subtly: the main agent never saw the result. The follow-up bypassed his session entirely. The chief of staff had no memory of what his team did.

The fix: a settle now drives a real main-agent turn. Oliver reads the report and interjects in his own voice ("just got something back, here's the read"), and you continue what you were talking about or branch into the new topic. The structural side-effect matters more than the presentation: Oliver's session now contains what his team did. He can reference outcomes days later because he processed them.

It's also agentic, not summarize-only. Oliver decides whether to act on what came back (dispatch follow-on work toward what you're going for) or ask you. The heuristic lives in his SOUL, not in the turn: act when the next step is unambiguous, low-cost, and inside the original ask's scope; ask when it branches, spends meaningfully, or is preference-shaped. When he acts without asking, he says so plainly: "the comparison was incomplete, so I sent it back to finish." No autonomy badge. Stating the decision every time is the trust contract.

The ceiling on autonomy is a plan, not a number. Oliver keeps a per-goal scratchpad (what he's driving toward, what's done, what's next) as a file in the member's workspace repo, written file-first and mirrored to the DB, so it's agent-side truth the UI can render. He acts within the plan and surfaces at milestones and plan changes. A depth backstop rides underneath as the safety net: 5 self-initiated continuations per goal, then a forced "I've paused, want me to keep going?"

Burst days are handled by batching, not by more rendering: settles park claim markers, a per-member turn lease drains them oldest-first into one turn (cap 5 reports / 20k chars; overflow relays verbatim, never dropped), and settle turns defer while any delegation is still running. Delegation reports are wrapped in fenced <oliver:delegation-report> blocks with an untrusted-data line. The team's output is data to the manager, not instructions.

Honesty rule: if the settle turn can't run, the verbatim relay still lands, collapsed, with a faint mono "Back from Bed Research" label. Exactly one member-facing output per delegation, never zero.

Shadows: run on the project, managed by Oliver

Shadows were the hardest thing to place. At runtime a shadow belongs to a project (shadows.project_id is NOT NULL) and each run executes on that project's agent in its own session. But standing work is not project furniture. You shouldn't have to open a project to find out whether the 7am scan ran.

The ruling: each active shadow is a muted-by-default thread in the sidebar, at the org level. Runs post into it; the run page renders the definition's steps as a left rail with the live run's state on them, and the selected run's story on the right. Failures and approval gates bubble to the Now pill and the Inbox. And Oliver mentions notable outcomes in the home thread with a link, because that's what a manager does with standing work: doesn't make you check on it, tells you when something's worth knowing. There is deliberately no Shadows panel on the project page.

The old shadow builder demoted to an "advanced editor" escape hatch. Creating and editing a shadow is conversational.

Rollout: deployed ≠ visible

The shell shipped behind a per-org flag, DB default false, with an env kill switch that wins over the DB. A shared fail-closed shouldLandOnChatHome() predicate (member gate, then org flag, wrapped so it never throws and always falls back to the legacy dashboard) decides where / and /dashboard send you. The board wasn't deleted; it's frozen, still serving orgs whose flag is off.

Once flipped for an org, there are no legacy detours. Running a shadow, reviewing an approval, opening the file hub: every operation is reachable in-shell. When we found the last ?activity= overlay still hosted on the legacy project page, we killed it outright rather than repointing it. A half-migration that still routes you through the old UI is a defect, not a bridge.

The register

The visual language is called "Reading Room": typography-first, hairlines over shadows, bubble-less Oliver rows beside a serif "O", cool-mist user bubbles, mono metadata, one breathe animation. The legacy identity carries forward on purpose. The K3 lockup anchors the sidebar; wordmark navy is the structural ink. K3 orange appears only when an agent is alive or blocked on you right now: working dots, the delegation trace, composer focus. The color grammar is the status grammar: navy = informational, orange = alive / needs you, faint mono = history, dashed ◇ = ambient standing work. Color encodes function, not decoration. Same rule as everything else on the surface.


What's next

  • Shadows list in the shell. Per-shadow threads exist; the list-level surface is the last named parity gap.

  • Live tool calls under a shadow step. The legacy overlay's one unique capability, watching Oliver's tool calls stream under the active step, isn't in the native shadow page yet.

  • Oliver holds the file. Upload-and-route v1 is UI-orchestrated (drop, pick project, existing endpoint). The version where Oliver stages the file himself and decides where it goes is droplet-side work.

  • Desktop. The messenger shape was chosen with a desktop wrap in mind; that's a later slice.


If you're building something similar, the through-line is this: find out where context actually lives in your system, and draw the UI around that. If your agents are an org, show the org. The user already has the mental model: "I ask my chief of staff and they handle it."

← back to logConnor Callahan