[FEATURE]: Generic UI Intent Channel for cross-client plugin-driven UX
Feature hasn't been suggested before.
- [x] I have verified this feature I'm about to request hasn't been suggested before.
Describe the enhancement you want to request
Summary
Add a generic "UI intent" event type to the server-client protocol that allows the server and plugins to emit declarative UI specs (forms, selects, confirms, progress indicators) that all clients (TUI, Desktop, Web) can render uniformly—without requiring new core features for each use case.
Motivation
PRs #5958 and #5563 both implement Claude Code's AskUserQuestion tool, but both are TUI-only. When the tool is triggered from Desktop or Web clients, the session hangs indefinitely because those clients don't have renderers for the new UI.
This highlights a broader architectural gap:
-
Plugins cannot emit events to clients — they can only receive events via the
eventhook - Each UI-driving feature requires per-client implementation — there's no shared primitive set
- Core accumulates one-off features — instead of providing extensible infrastructure
The Permission system (packages/opencode/src/permission/index.ts:100-153) already demonstrates the correct pattern:
- Server emits a declarative intent (
permission.updatedevent) - Execution suspends via Promise
- Client renders appropriate UI (permission prompt)
- User responds via API (
POST /session/:id/permissions/:id) - Promise resolves, execution continues
A generic UI intent channel would apply this same pattern to arbitrary UI interactions.
Proposed Solution
1. Define UI Intent Event Schema
// packages/opencode/src/ui-intent/index.ts
import { z } from "zod"
import { BusEvent } from "../bus/bus-event"
export const FormField = z.discriminatedUnion("type", [
z.object({
type: z.literal("select"),
id: z.string(),
label: z.string(),
description: z.string().optional(),
options: z.array(
z.object({
value: z.string(),
label: z.string(),
description: z.string().optional(),
}),
),
default: z.string().optional(),
}),
z.object({
type: z.literal("multiselect"),
id: z.string(),
label: z.string(),
description: z.string().optional(),
options: z.array(
z.object({
value: z.string(),
label: z.string(),
description: z.string().optional(),
}),
),
default: z.array(z.string()).optional(),
}),
z.object({
type: z.literal("confirm"),
id: z.string(),
label: z.string(),
description: z.string().optional(),
default: z.boolean().optional(),
}),
z.object({
type: z.literal("text"),
id: z.string(),
label: z.string(),
description: z.string().optional(),
placeholder: z.string().optional(),
default: z.string().optional(),
}),
])
export const UIIntent = z.object({
id: z.string(),
sessionID: z.string(),
messageID: z.string(),
callID: z.string().optional(),
source: z.enum(["core", "plugin"]),
plugin: z.string().optional(),
intent: z.discriminatedUnion("type", [
z.object({
type: z.literal("form"),
title: z.string(),
description: z.string().optional(),
fields: z.array(FormField).min(1).max(10),
submitLabel: z.string().default("Submit"),
cancelLabel: z.string().default("Cancel"),
}),
z.object({
type: z.literal("confirm"),
title: z.string(),
message: z.string(),
confirmLabel: z.string().default("Confirm"),
cancelLabel: z.string().default("Cancel"),
variant: z.enum(["info", "warning", "danger"]).default("info"),
}),
z.object({
type: z.literal("progress"),
title: z.string(),
message: z.string().optional(),
value: z.number().min(0).max(100).optional(),
indeterminate: z.boolean().default(false),
cancellable: z.boolean().default(false),
}),
z.object({
type: z.literal("toast"),
message: z.string(),
variant: z.enum(["info", "success", "warning", "error"]),
duration: z.number().default(5000),
}),
]),
})
export const UIIntentResponse = z.object({
intentID: z.string(),
sessionID: z.string(),
response: z.union([
z.object({ type: z.literal("submit"), data: z.record(z.string(), z.any()) }),
z.object({ type: z.literal("cancel") }),
]),
})
export const Event = {
Request: BusEvent.define("ui.intent.request", UIIntent),
Response: BusEvent.define("ui.intent.response", UIIntentResponse),
}
2. Add Server API Endpoint
// packages/opencode/src/server/server.ts
.post("/session/:sessionID/ui-intent/:intentID", async (c) => {
const { sessionID, intentID } = c.req.param()
const body = UIIntentResponse.parse(await c.req.json())
await UIIntent.respond({ sessionID, intentID, response: body.response })
return c.json({ success: true })
})
3. Expose to Plugins
// packages/plugin/src/index.ts
export type PluginInput = {
// ... existing fields
ui: {
form: (input: FormIntent) => Promise<Record<string, any>>
confirm: (input: ConfirmIntent) => Promise<boolean>
toast: (input: ToastIntent) => void
}
}
4. Implement Client Renderers
Each client (TUI, Desktop, Web) implements renderers for the primitive set:
| Primitive | TUI | Desktop/Web |
|---|---|---|
form |
Dialog with fields (port from #5958/#5563) | Modal form |
confirm |
Yes/No dialog | Confirmation modal |
select |
Selection list | Dropdown/radio group |
multiselect |
Checkbox list | Checkbox group |
progress |
Progress bar | Progress indicator |
toast |
Toast notification | Toast notification |
Use Cases Enabled
| Use Case | Intent Type | Description |
|---|---|---|
| AskUserQuestion | form |
Claude Code-style structured questions |
| Plugin setup | form |
OAuth flows, API key entry, configuration |
| Destructive confirmation | confirm |
"Delete 47 files?" |
| Choice selection | form with select |
"Which database?" |
| Feature selection | form with multiselect |
"Select features to enable" |
| Long operation | progress |
Cancellable progress for migrations |
| Notifications | toast |
Non-blocking status updates |
Design Decisions
Why not just merge #5958 or #5563?
Both PRs solve the immediate problem but:
- TUI-only — Desktop/Web clients hang
- Not extensible — each new UI need requires new protocol additions
- Core-only — plugins can't drive UX
Why use the Permission pattern?
It already solves:
- Execution suspension via Promise
- Client notification via Bus events
- Response routing via API + pending map
- Multi-client safety (only one responds)
Why declarative schemas instead of arbitrary UI?
- Security: Plugins can't inject arbitrary code/markup
- Consistency: All clients render the same primitives
- Validation: Zod schemas enforce constraints at runtime
- Theming: Clients control presentation, intents control content
Related
- #5958 — AskQuestion wizard implementation (TUI-only)
- #5563 — Ask tool dialog implementation (TUI-only)
- #3844 — Original request for AskUserQuestion
References
- Claude Code AskUserQuestion schema: https://gist.github.com/bgauryy/0cdb9aa337d01ae5bd0c803943aa36bd
- Permission system:
packages/opencode/src/permission/index.ts:100-153 - Bus events:
packages/opencode/src/bus/index.ts:41-64 - Plugin hooks:
packages/plugin/src/index.ts:145-209
This issue might be a duplicate of existing issues. Please check:
- #5147: [FEATURE]: Plugin Support for Modal Human-in-the-loop Interactions — Requests similar plugin-driven UI capability with modal dialogs for human-in-the-loop interactions
- #5148: [FEATURE]: Comprehensive Plugin Pipeline - Middleware-Style Data Flow Control — Addresses the broader architectural need for plugins to emit events and drive UX via middleware patterns
These issues share the same core goal: enabling plugins to drive UI interactions and get user responses back to the pipeline, similar to the Permission system pattern you've outlined.
Feel free to ignore if this feature request addresses a different architectural approach.
@rekram1-node Any thoughts on this one? Would be willing to put in the work for a PR if you agree with the approach
hey @malhashemi i took a stab at this because i wanted to get the askuserquestion tool in opencode. the changes in the fork are all related to exposing (ui) intents to plugins, and i developed the askuserquestion tool as a plugin with a separate companion skill for guidance on asking good questions depending on context.
heres the PR draft if you want to have a look. i have tested it locally with the plugin tool.
For ui plugins the team wants to make some decisions before we start implementing these things. @thdxr knows best here
@rekram1-node I have figured that out as well, that's why I haven't gone straight with a PR, wanted to get some input from @thdxr first on the direction you would like to take for this one.
hey @malhashemi i took a stab at this because i wanted to get the askuserquestion tool in opencode. the changes in the fork are all related to exposing (ui) intents to plugins, and i developed the askuserquestion tool as a plugin with a separate companion skill for guidance on asking good questions depending on context.
heres the PR draft if you want to have a look. i have tested it locally with the plugin tool.
Thanks @the-vampiire !!!
Cool approach, however for such a core feature better to get a feedback from the devs first.
understood, i got overzealous going down a rabbit hole to get the ask user question tool working.
i'm nowhere near dax's architectural ability, but i did put thought into the design and adapting to the existing opencode patterns so it feels native instead of bolted on.
@malhashemi your proposal was instrumental, really good thinking there.
hopefully it will help or give something tangible to feel. but no worries at all if you want to close it and go a different direction.
i'll keep the fork running locally until we get the official design. having that interactivity has already opened up a lot of new workflows. i'm stoked to see what you all decide on. if there's anything i can help with let me know.
happy new years guys, be safe.
Thanks @the-vampiire ,
I agree that the ask questions tool is getting requested a lot by the community, and I really hope we get a similar result to what this issue is proposing regardless of the implementation.
OpenCode plugins are already very powerful but they would be way more useful if they are allowed to render their own UI somehow. I am sure @thdxr already thought about that and might have it on the agenda at a certain point.
Happy new year everyone!!! Can't imagine what 2026 holds for OpenCode!
How does this allow for things like changing the form definition when I select something in the drop-down?
Would that be an ever growing list of things that need to be filled into the response schema?
How does this approach allow me to create my own UI paradigms?
List of things I'm keen on creating/using
Side drawers
Buttons with custom label divisions
Status bar
Tables with draggable cards
Maybe I don't know enough about how the internals already render and maybe we're already painting ourselves into a corner with a technical debit credit card so large it's getting harder to scale it of...
But I had hoped that my plugins would be able to just render their own react/solid components.
I imagined a registry service that a plugin would be required to give a component for each mode web/tui/text.
Thank you @malhashemi for starting this discussion. I think it’s an important one for the plugin ecosystem in OpenCode.
I want to clarify what I see as two separate needs that are currently being conflated:
-
HITL (Human-in-the-loop) primitives
Plugins need a way to pause execution and solicit structured input from the user, such as confirm or deny, select from options, or provide a value. This is what the current proposal is addressing: a protocol-level intent that clients can render and then resolve back to the plugin. -
Custom UI rendering
Plugins rendering their own persistent or complex UI, such as custom React or Solid components, drawers, tables, status bars, and similar surfaces.
These are very different scopes.
The first is fundamentally a protocol problem: emit a declarative intent, suspend execution, and receive a response. This mirrors how the existing permission prompt works and is compatible across clients.
The second is effectively a full component system with client-specific implementation complexity, lifecycle concerns, and significant architectural implications. It's supporting "the whole world" vs just a subsection of it.
I’m currently building an orchestration plugin that depends on the first capability. I need supervised agent checkpoints where execution must pause, show the user what is about to happen, and wait for approval/feedback before continuing. At the moment, plugins can return messages, but they cannot block and wait for structured user input. The primitives proposed here would solve that directly.
My preference would be to ship the HITL primitives first. Custom UI rendering is a much larger architectural decision and I would prefer that it not block the immediate need for structured suspend-and-resume interactions. These primitives also feel like the natural building blocks that a future custom UI system would build on top of.
That said, I’m open to @thdxr’s perspective on whether splitting these concerns introduces tech debt or whether these primitives are a solid enough foundation for future extension.
@eXamadeus i agree on separating those scopes and how the getting the HITL / intent and UI primitives makes sense to have in place before the much broader task of generic UI extensions.
it also makes sense to narrow the scope to just the server foundation (client intents and two way communication) and TUI client UI primitives. the TUI is the original and primary client. it's simpler to develop and reason about compared to web that has more UI/UX nuance. getting out for the TUI can give time to iterate and inform the web design.
hey guys, ask question is built in now!
after looking over the implementation, they chose to go in a different direction.
i still think this is a great idea and would open up a lot of possibilities for plugins and future enhancements. but i think it's unlikely this foundation will be added as it would take significant refactoring to integrate now so i've closed the PR.
Now I guess the major hurdle is just the custom UI, since (I haven't fully tested it yet) the question tooling seems to fill in the HITL primitive niche nicely.
I took a swing at exposing the question.ask capabilities via API/SDK here: #8404
The goal is to allow plugins to call into this new native question tooling once the SDK is upgraded to v2 #7641, #7147
So, I have a few PRs up that will enable this functionality, but here is a teaser:
That is an HITL blocking response from a plugin!
The PR I mentioned earlier #8404 exposes the question.ask functionality through the SDK and #8380 allows plugins to use the new (v2) SDK. I was able to get this working off my local fork 😄